Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
479e434f92 | ||
|
|
9eb0f29cef | ||
|
|
0b29404031 | ||
|
|
5463f58933 | ||
|
|
5032965e3a | ||
|
|
57a52b1a99 | ||
|
|
e33b8d3712 | ||
|
|
344dc41ce2 | ||
|
|
620ed6e9a9 | ||
|
|
a30a3ce4c3 | ||
|
|
1a97ced133 | ||
|
|
3d0c13fa5a | ||
|
|
0f19773076 | ||
|
|
aa4fe1cc7b | ||
|
|
6b58f04d39 | ||
|
|
35e94e107c | ||
|
|
1ec4672fad | ||
|
|
7ecf7bf2d6 | ||
|
|
0589ec8069 | ||
|
|
300e8acd13 | ||
|
|
a002864a06 | ||
|
|
8e149e6cfa | ||
|
|
ed0e8c82de | ||
|
|
df3167488c | ||
|
|
ddc9b97d40 | ||
|
|
1d11cbab0f | ||
|
|
d17f055e86 | ||
|
|
52ded0ea71 | ||
|
|
296601647d | ||
|
|
702ceb2480 | ||
|
|
6c15aa88b3 | ||
|
|
c31df2130c | ||
|
|
ccfaa0ec0c | ||
|
|
ca76dacd73 | ||
|
|
ad13d872df | ||
|
|
0c2f45abb7 | ||
|
|
5ed2ab8a38 | ||
|
|
0568f44cb2 | ||
|
|
ab34280f90 | ||
|
|
9bf3acfef6 | ||
|
|
059ee77c1f | ||
|
|
bc968dd2e0 | ||
|
|
716fc21a0d | ||
|
|
edaeede250 | ||
|
|
5547399037 | ||
|
|
cb6ae0ca50 | ||
|
|
d12adabeb1 | ||
|
|
4b8a9219d8 | ||
|
|
e168978579 | ||
|
|
fcf6981b1b | ||
|
|
d181d499d3 | ||
|
|
b7a5284b98 | ||
|
|
6b568d8805 | ||
|
|
7f2b9f36de | ||
|
|
8a851eb87e | ||
|
|
ad59053cd7 | ||
|
|
854818e65a | ||
|
|
9d2c652ae8 | ||
|
|
adc61255b2 | ||
|
|
bde5c5fb20 | ||
|
|
a517655aad | ||
|
|
447595b72b | ||
|
|
5e0e474350 | ||
|
|
043df0f7df | ||
|
|
f858c1d1b2 | ||
|
|
08f67007c5 | ||
|
|
3edeba4d7f | ||
|
|
0425bf9a43 | ||
|
|
03c64a3219 | ||
|
|
eac7afe5cb | ||
|
|
e349839fd7 | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b00e09a781 | ||
|
|
b05075fd25 | ||
|
|
61c3a57df5 | ||
|
|
c908ed6050 | ||
|
|
0a38d92382 | ||
|
|
fde95b9266 | ||
|
|
7ae5f3a541 | ||
|
|
22d0fdd251 | ||
|
|
15a8a76e99 | ||
|
|
a8d2087b4a | ||
|
|
61c0d73cd1 | ||
|
|
6a0d7bbef4 | ||
|
|
29d96c8946 | ||
|
|
8d8d2d8f81 | ||
|
|
0d8a2c2b1d | ||
|
|
11d1d2e99f | ||
|
|
3990fc684f | ||
|
|
daf60266d0 | ||
|
|
7e5eca08b3 | ||
|
|
39e27dc63f | ||
|
|
7eb4884658 | ||
|
|
3e761206e5 | ||
|
|
0801e2455b | ||
|
|
ec2433f13b | ||
|
|
899ef8ec7f | ||
|
|
58d6b5844f | ||
|
|
c1c61e9b15 | ||
|
|
fca3296883 | ||
|
|
d7b0b0e772 | ||
|
|
83bc7246ff | ||
|
|
29c2cf2ef6 | ||
|
|
4a160e0e0c | ||
|
|
14d885b2d2 | ||
|
|
a3dbf668fd | ||
|
|
fb9191e559 | ||
|
|
07083eae09 | ||
|
|
d867acd9db | ||
|
|
a19af4de22 | ||
|
|
751403d12b | ||
|
|
3e78db5d28 | ||
|
|
e33d8874a5 | ||
|
|
98fbfb3de7 | ||
|
|
8814c04e3a | ||
|
|
7c77df0c52 | ||
|
|
c54f41c38d | ||
|
|
f34ec86b90 | ||
|
|
7cb65028fd | ||
|
|
f65069d22d | ||
|
|
9617b64a77 | ||
|
|
4f894009f6 | ||
|
|
acf2eaec01 | ||
|
|
e8ca5202a0 | ||
|
|
99640f90d0 | ||
|
|
1421fa8568 | ||
|
|
9e144db75c | ||
|
|
c780ded653 | ||
|
|
67e4a2b5e9 | ||
|
|
80f59b334e | ||
|
|
dc899d23c8 | ||
|
|
943d40270e | ||
|
|
d936da5c87 | ||
|
|
29ffe1407f | ||
|
|
632c568865 | ||
|
|
c2fc2683b9 | ||
|
|
889931d553 | ||
|
|
1844e29880 | ||
|
|
3fd02a3c63 | ||
|
|
ea8e186549 | ||
|
|
78cc37a977 | ||
|
|
23e366d9d6 | ||
|
|
324b4b3e93 |
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
|
||||
# only — never the token value. Surfaced by gitea_get_profile.
|
||||
GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
|
||||
# ── Optional self-hosted Sentry observability (#606) ────────────────────────
|
||||
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
|
||||
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
|
||||
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
|
||||
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
|
||||
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
|
||||
#
|
||||
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
|
||||
MCP_SENTRY_ENABLED=0
|
||||
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
|
||||
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
|
||||
SENTRY_DSN=
|
||||
# Deployment environment tag (local/dev/prod). Defaults to "development".
|
||||
SENTRY_ENVIRONMENT=development
|
||||
# Optional release identifier (e.g. a git SHA or version string).
|
||||
SENTRY_RELEASE=
|
||||
# Performance-trace sample rate, 0.0–1.0 (clamped). Default 0.0 (traces off).
|
||||
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
|
||||
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
|
||||
MCP_SENTRY_ENABLE_LOGS=0
|
||||
|
||||
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
||||
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
||||
# Secrets are referenced (keychain id / env var name), never inlined. See
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
|
||||
|
||||
The work allocator previously inferred dependency state from two lowercase
|
||||
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
|
||||
canonical declaration form is a ``Depends:`` field inside the issue body's
|
||||
linkage line, for example::
|
||||
|
||||
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
|
||||
|
||||
That form matched neither substring, so dependency-blocked issues were emitted
|
||||
as eligible candidates. This module replaces substring inference with:
|
||||
|
||||
1. structured parsing of ``Depends:`` declarations into issue references, and
|
||||
2. resolution of each reference against **live issue state**, never body text.
|
||||
|
||||
Both halves fail closed: a reference whose state cannot be established makes
|
||||
the owning candidate ineligible rather than assignable.
|
||||
|
||||
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
|
||||
entirely by the declaration syntax.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable, Iterable
|
||||
|
||||
# Live issue states, as reported by Gitea.
|
||||
DEP_STATE_OPEN = "open"
|
||||
DEP_STATE_CLOSED = "closed"
|
||||
|
||||
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
|
||||
# not match: after "depend" it continues with "e", not "s".
|
||||
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
|
||||
|
||||
# Immediately after the keyword, consume only the contiguous run of issue
|
||||
# references. Anchoring the run this way means the declaration ends naturally
|
||||
# at the next separator ("·", newline) or sibling field ("Related:"), without
|
||||
# needing to enumerate separators.
|
||||
_DEP_RUN = re.compile(
|
||||
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Legacy marker retained so previously-recognized bodies keep working.
|
||||
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
|
||||
|
||||
_ISSUE_REF = re.compile(r"#(\d+)")
|
||||
|
||||
|
||||
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
|
||||
"""Extract declared dependency issue numbers from an issue *body*.
|
||||
|
||||
Recognizes the canonical ``Depends: #N, #N`` field (including the
|
||||
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
|
||||
Returns references in first-seen order with duplicates removed. Malformed
|
||||
or absent declarations yield an empty tuple rather than raising.
|
||||
"""
|
||||
if not body:
|
||||
return ()
|
||||
|
||||
refs: list[int] = []
|
||||
|
||||
def _add(value: str) -> None:
|
||||
number = int(value)
|
||||
if number > 0 and number not in refs:
|
||||
refs.append(number)
|
||||
|
||||
for match in _DEPENDS_KEYWORD.finditer(body):
|
||||
run = _DEP_RUN.match(body, match.end())
|
||||
if not run:
|
||||
continue
|
||||
for ref in _ISSUE_REF.findall(run.group(1)):
|
||||
_add(ref)
|
||||
|
||||
for ref in _LEGACY_BLOCKED.findall(body):
|
||||
_add(ref)
|
||||
|
||||
return tuple(refs)
|
||||
|
||||
|
||||
def resolve_dependency_state(
|
||||
refs: Iterable[int],
|
||||
state_lookup: Callable[[int], str | None],
|
||||
*,
|
||||
subject: str = "candidate",
|
||||
) -> dict:
|
||||
"""Resolve declared *refs* against live issue state.
|
||||
|
||||
*state_lookup* maps an issue number to its live state string, or to
|
||||
``None`` when that evidence could not be obtained. A reference is:
|
||||
|
||||
* **met** when live state is ``closed``;
|
||||
* **unmet** when live state is any other live value (``open``, etc.);
|
||||
* **unavailable** when state is ``None`` or the lookup raises.
|
||||
|
||||
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
|
||||
allocation must never assume a dependency is satisfied.
|
||||
"""
|
||||
unmet: list[int] = []
|
||||
unavailable: list[int] = []
|
||||
met: list[int] = []
|
||||
|
||||
for ref in refs:
|
||||
try:
|
||||
number = int(ref)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
state = state_lookup(number)
|
||||
except Exception: # noqa: BLE001 — unavailable evidence fails closed
|
||||
state = None
|
||||
normalized = (str(state).strip().lower() if state is not None else "") or None
|
||||
if normalized is None:
|
||||
unavailable.append(number)
|
||||
elif normalized == DEP_STATE_CLOSED:
|
||||
met.append(number)
|
||||
else:
|
||||
unmet.append(number)
|
||||
|
||||
reason: str | None = None
|
||||
if unmet and unavailable:
|
||||
reason = (
|
||||
f"{subject} has unresolved dependencies "
|
||||
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
|
||||
"(fail closed)"
|
||||
)
|
||||
elif unmet:
|
||||
reason = (
|
||||
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
|
||||
"they are not closed"
|
||||
)
|
||||
elif unavailable:
|
||||
reason = (
|
||||
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
return {
|
||||
"refs": tuple(int(x) for x in refs),
|
||||
"met": tuple(met),
|
||||
"unmet": tuple(unmet),
|
||||
"unavailable": tuple(unavailable),
|
||||
"dependency_unmet": bool(unmet or unavailable),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _fmt(numbers: Iterable[int]) -> str:
|
||||
return ", ".join(f"#{n}" for n in numbers)
|
||||
+502
-11
@@ -18,10 +18,12 @@ after they exist as normal issues; this module never assigns incidents.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Sequence
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
@@ -40,6 +42,31 @@ OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||
OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
# #765: ownership could not be established for every remaining candidate.
|
||||
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
|
||||
# #776: excluded issue still carries a live same-owner lease — resume or release.
|
||||
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE = "blocked_by_excluded_own_lease"
|
||||
# #776: dry-run/apply candidate-set fingerprint mismatch (CAS drift).
|
||||
OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||
|
||||
# #765 skip reason code for work already claimed by a different controller.
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||
# #776: controller-supplied pre-rank exclusion.
|
||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||
|
||||
# Ownership verdicts for a live claim on a candidate (#765).
|
||||
OWNERSHIP_OWN = "own"
|
||||
OWNERSHIP_FOREIGN = "foreign"
|
||||
OWNERSHIP_UNKNOWN = "unknown"
|
||||
|
||||
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
|
||||
# alongside allocator results so the flat status:ready tier and its
|
||||
# oldest-number tie-break are explicit rather than incidental.
|
||||
SELECTION_POLICY = (
|
||||
"rank complete inventory by (priority desc, PRs before issues, "
|
||||
"number asc); status:ready issues share priority 20, so the oldest "
|
||||
"eligible number wins ties; result limits never affect selection"
|
||||
)
|
||||
|
||||
ROLE_AUTHOR = "author"
|
||||
ROLE_REVIEWER = "reviewer"
|
||||
@@ -135,9 +162,76 @@ class SkipRecord:
|
||||
kind: str
|
||||
number: int
|
||||
reason: str
|
||||
reason_code: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"number": self.number,
|
||||
"reason": self.reason,
|
||||
"reason_code": self.reason_code,
|
||||
}
|
||||
|
||||
|
||||
CONTROLLER_INSTANCE_ENV = "GITEA_CONTROLLER_INSTANCE_ID"
|
||||
|
||||
|
||||
def resolve_controller_instance_id(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Return this controller's stable identity, or ``None`` if undeclared.
|
||||
|
||||
Deliberately has no derived fallback. The obvious candidates are unsafe:
|
||||
``session_id`` is regenerated per invocation, and the MCP process pid is
|
||||
shared by every controller attached to the same daemon — two independent
|
||||
controllers really do report the same pid and profile. Guessing from either
|
||||
would let one controller adopt another's lease, which is the failure #765
|
||||
exists to prevent. When this returns ``None``, live claims are treated as
|
||||
unidentified: they are excluded from selection and reported as ownership
|
||||
defects rather than adopted.
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(CONTROLLER_INSTANCE_ENV) or "").strip() or None
|
||||
|
||||
|
||||
def classify_claim_ownership(
|
||||
claim: dict[str, Any] | None,
|
||||
*,
|
||||
session_id: str | None,
|
||||
controller_instance_id: str | None,
|
||||
) -> str | None:
|
||||
"""Classify a live claim as own / foreign / unknown ownership (#765).
|
||||
|
||||
Returns ``None`` when the candidate carries no live claim.
|
||||
|
||||
Session ids are regenerated per allocator invocation, so they only prove
|
||||
ownership positively (an exact match is certainly this session). The
|
||||
durable signal is ``controller_instance_id``. When either side lacks one,
|
||||
ownership is *unknown*: the allocator must not assume that a lease sharing
|
||||
the same profile belongs to this controller, so unknown is treated as
|
||||
not-ours for selection purposes and reported as an ownership defect.
|
||||
"""
|
||||
if not claim:
|
||||
return None
|
||||
claim_session = str(claim.get("session_id") or "").strip()
|
||||
claim_instance = str(claim.get("controller_instance_id") or "").strip()
|
||||
own_session = str(session_id or "").strip()
|
||||
own_instance = str(controller_instance_id or "").strip()
|
||||
|
||||
if claim_session and own_session and claim_session == own_session:
|
||||
return OWNERSHIP_OWN
|
||||
if claim_instance and own_instance:
|
||||
return (
|
||||
OWNERSHIP_OWN if claim_instance == own_instance else OWNERSHIP_FOREIGN
|
||||
)
|
||||
if not claim_instance and not own_instance:
|
||||
# Neither side declares a controller identity. The session ids differ
|
||||
# (an exact match returned OWN above), so this is simply someone
|
||||
# else's lease: foreign, and we wait rather than adopt.
|
||||
return OWNERSHIP_FOREIGN
|
||||
# Exactly one side is identified, so the two cannot be compared: this may
|
||||
# or may not be our own task under a different session id. Never guess.
|
||||
return OWNERSHIP_UNKNOWN
|
||||
|
||||
|
||||
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
@@ -194,8 +288,16 @@ def classify_skip(
|
||||
*,
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
claim_ownership: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||
"""Return skip reason, or None if candidate is selectable for *role*.
|
||||
|
||||
*claim_ownership* (#765) is the verdict from
|
||||
:func:`classify_claim_ownership` for this candidate's live claim. Foreign
|
||||
and unknown claims are excluded so one session's in-progress task can never
|
||||
blockade the queue for a different controller; ``own`` stays selectable so
|
||||
a controller can resume its own work.
|
||||
"""
|
||||
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:
|
||||
@@ -205,6 +307,16 @@ def classify_skip(
|
||||
c.dependency_reason
|
||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||
)
|
||||
if claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN):
|
||||
detail = (
|
||||
"owned by another controller instance"
|
||||
if claim_ownership == OWNERSHIP_FOREIGN
|
||||
else "owner could not be identified; never adopt on a guess"
|
||||
)
|
||||
return (
|
||||
f"{c.kind}#{c.number} {SKIP_CLAIMED_BY_OTHER_SESSION}: "
|
||||
f"active lease {detail}"
|
||||
)
|
||||
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():
|
||||
@@ -242,13 +354,136 @@ def classify_skip(
|
||||
|
||||
|
||||
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||
"""Higher priority first; then lower number (older issues) for stability."""
|
||||
"""Rank candidates deterministically (#758 AC10).
|
||||
|
||||
Ordering key, in precedence order:
|
||||
|
||||
1. ``priority`` descending — the loader scores ``status:ready`` issues at
|
||||
20 and everything else at 1, so the ready queue ties at a single value
|
||||
by design;
|
||||
2. PRs before issues — in-flight review work drains before new authoring;
|
||||
3. ``number`` ascending — oldest first, which is what actually breaks the
|
||||
flat ``status:ready`` tie.
|
||||
|
||||
Because the ready tier is intentionally flat, rule 3 decides most real
|
||||
selections. That is only safe when ranking sees the *complete* candidate
|
||||
inventory: truncating before this call silently redefines "oldest" as
|
||||
"oldest among whatever survived the slice", which is the defect #758
|
||||
fixed. Callers must rank everything and bound reporting afterwards.
|
||||
"""
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||
)
|
||||
|
||||
|
||||
def _require_strict_int(value: Any, *, field: str) -> int:
|
||||
"""Parse an issue number; reject bools and non-integers (#776)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"{field} must be an integer (booleans and non-integers rejected; "
|
||||
f"got {type(value).__name__})"
|
||||
)
|
||||
return int(value)
|
||||
|
||||
|
||||
def normalize_exclude_issue_numbers(
|
||||
exclude_issue_numbers: Any = None,
|
||||
) -> list[int]:
|
||||
"""Normalize controller-supplied exclusions to a sorted unique int list (#776).
|
||||
|
||||
``None`` / omitted → empty list (existing behavior). Accepts a list/tuple of
|
||||
integers. Rejects scalars, bools-as-ints, nested structures, and strings.
|
||||
"""
|
||||
if exclude_issue_numbers is None:
|
||||
return []
|
||||
if isinstance(exclude_issue_numbers, (str, bytes)) or not isinstance(
|
||||
exclude_issue_numbers, (list, tuple)
|
||||
):
|
||||
raise ValueError(
|
||||
"exclude_issue_numbers must be a list of integers "
|
||||
f"(got {type(exclude_issue_numbers).__name__})"
|
||||
)
|
||||
out: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for idx, raw in enumerate(exclude_issue_numbers):
|
||||
num = _require_strict_int(raw, field=f"exclude_issue_numbers[{idx}]")
|
||||
if num not in seen:
|
||||
seen.add(num)
|
||||
out.append(num)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def candidate_set_fingerprint(
|
||||
candidates: Sequence[WorkCandidate],
|
||||
*,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
) -> str:
|
||||
"""Stable CAS fingerprint of normalized candidate set + exclusions (#776 AC4)."""
|
||||
payload = {
|
||||
"candidates": sorted(
|
||||
({"kind": c.kind, "number": int(c.number)} for c in candidates),
|
||||
key=lambda x: (x["kind"], x["number"]),
|
||||
),
|
||||
"exclude_issue_numbers": list(
|
||||
normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
),
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_candidates_payload(raw: Any) -> list[WorkCandidate]:
|
||||
"""Decode MCP ``candidates_json`` from list or JSON string (#776 AC3).
|
||||
|
||||
Accepts:
|
||||
* an already-decoded ``list`` of candidate dicts (native MCP transport);
|
||||
* a valid JSON string that decodes to such a list (backward compatible).
|
||||
|
||||
Rejects malformed JSON, scalars, non-list containers, invalid records,
|
||||
booleans-as-integers, and unsupported types with fail-closed ``ValueError``.
|
||||
"""
|
||||
if raw is None:
|
||||
raise ValueError("candidates_json is empty")
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
f"candidates_json bytes are not valid utf-8: {exc}"
|
||||
) from exc
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise ValueError("candidates_json string is empty")
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"malformed candidates_json JSON: {exc.msg} at pos {exc.pos}"
|
||||
) from exc
|
||||
raw = decoded
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(
|
||||
"candidates_json must be a JSON list (or already-decoded list); "
|
||||
f"got {type(raw).__name__}"
|
||||
)
|
||||
candidates: list[WorkCandidate] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: expected object, "
|
||||
f"got {type(item).__name__}"
|
||||
)
|
||||
try:
|
||||
candidates.append(candidate_from_dict(item))
|
||||
except (KeyError, TypeError, ValueError, InvalidWorkKindError) as exc:
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: {exc}"
|
||||
) from exc
|
||||
return candidates
|
||||
|
||||
|
||||
def allocate_next_work(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
@@ -262,12 +497,22 @@ def allocate_next_work(
|
||||
profile_name: str | None = None,
|
||||
username: str | None = None,
|
||||
lease_ttl_seconds: int | None = None,
|
||||
controller_instance_id: str | None = None,
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
expected_candidate_set_fingerprint: str | 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.
|
||||
|
||||
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||
empty preserves prior behavior.
|
||||
|
||||
*expected_candidate_set_fingerprint* (#776 AC4): when set on apply, rejects
|
||||
material candidate-set drift vs a prior dry-run.
|
||||
|
||||
Never uses file locks or comment-only leases as the assignment source.
|
||||
"""
|
||||
if db is None:
|
||||
@@ -303,6 +548,7 @@ def allocate_next_work(
|
||||
role=role_norm,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface structured
|
||||
return {
|
||||
@@ -344,30 +590,238 @@ def allocate_next_work(
|
||||
}
|
||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||
|
||||
# #765: live claims exclude work owned by a *different* controller before
|
||||
# ranking, so one session's in-progress task cannot blockade the queue.
|
||||
# #776 AC5: load live claims in this call path immediately before selection
|
||||
# (and before apply reserve) so ownership is never stale within the
|
||||
# allocation attempt. Test callers may inject *claims* explicitly.
|
||||
if claims is None:
|
||||
try:
|
||||
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
f"active claim lookup failed: {exc} (fail closed, #765)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
try:
|
||||
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
f"invalid exclude_issue_numbers: {exc} (fail closed, #776)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
exclude_set = set(exclude_nums)
|
||||
cas_fp = candidate_set_fingerprint(
|
||||
candidates, exclude_issue_numbers=exclude_nums
|
||||
)
|
||||
expected_fp = (expected_candidate_set_fingerprint or "").strip() or None
|
||||
if expected_fp and expected_fp != cas_fp:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_CANDIDATE_SET_DRIFT,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
"candidate-set fingerprint drift: apply rejected rather than "
|
||||
"silently leasing a different candidate (#776 AC4)"
|
||||
],
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"expected_candidate_set_fingerprint": expected_fp,
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
skipped: list[SkipRecord] = []
|
||||
ordered = sort_candidates(list(candidates))
|
||||
claims_excluded: list[dict[str, Any]] = []
|
||||
ownership_defects: list[dict[str, Any]] = []
|
||||
controller_excluded: list[dict[str, Any]] = []
|
||||
|
||||
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||
rankable: list[WorkCandidate] = []
|
||||
for c in candidates:
|
||||
if int(c.number) in exclude_set:
|
||||
reason = (
|
||||
f"{c.kind}#{c.number} {SKIP_EXCLUDED_BY_CONTROLLER}: "
|
||||
"controller pre-rank exclusion"
|
||||
)
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
)
|
||||
)
|
||||
controller_excluded.append(
|
||||
{
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"reason_code": SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
}
|
||||
)
|
||||
# #776 AC5: same-owner live lease on an excluded issue is a
|
||||
# structured resume/release blocker, never a silent strand.
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
if ownership == OWNERSHIP_OWN and claim:
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
"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": [
|
||||
f"{c.kind}#{c.number} is excluded_by_controller but "
|
||||
"carries a live same-owner lease; resume or release "
|
||||
"that lease before allocating other work (#776 AC5)"
|
||||
],
|
||||
"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,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"blocked_lease": {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
"safe_next_action": (
|
||||
"resume the same-owner lease or release it, then "
|
||||
"re-run allocation without stranding the excluded "
|
||||
"issue"
|
||||
),
|
||||
},
|
||||
}
|
||||
continue
|
||||
rankable.append(c)
|
||||
|
||||
ordered = sort_candidates(rankable)
|
||||
selected: WorkCandidate | None = None
|
||||
for c in ordered:
|
||||
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
reason = classify_skip(
|
||||
c,
|
||||
role=role_norm,
|
||||
terminal_pr=terminal_pr,
|
||||
claim_ownership=ownership,
|
||||
)
|
||||
if reason:
|
||||
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION if is_claim_skip else None,
|
||||
)
|
||||
)
|
||||
if is_claim_skip and claim:
|
||||
record = {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"ownership": ownership,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
}
|
||||
claims_excluded.append(record)
|
||||
if ownership == OWNERSHIP_UNKNOWN:
|
||||
ownership_defects.append(record)
|
||||
continue
|
||||
selected = c
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# If terminal lock blocks all review work, surface that explicitly.
|
||||
owner_session_id: str | None = None
|
||||
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)"
|
||||
]
|
||||
elif ownership_defects:
|
||||
# #765: every remaining candidate is claimed and at least one owner
|
||||
# could not be identified. Report the defect; never adopt.
|
||||
outcome = OUTCOME_OWNERSHIP_DEFECT
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}': "
|
||||
f"{len(ownership_defects)} candidate(s) carry an active lease "
|
||||
"whose controller ownership could not be established. Record a "
|
||||
"controller_instance_id on those sessions; the allocator will "
|
||||
"not assume a shared profile means shared ownership (#765)."
|
||||
]
|
||||
elif claims_excluded:
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no unclaimed work for role '{role_norm}': "
|
||||
f"{len(claims_excluded)} candidate(s) are actively claimed by "
|
||||
"another controller. Waiting; their leases are not adopted (#765)."
|
||||
]
|
||||
# Preserve the pre-#765 wait contract: name the blocking owner.
|
||||
owner_session_id = claims_excluded[0].get("owner_session_id")
|
||||
elif controller_excluded and not ordered:
|
||||
# #776 AC7: every candidate was controller-excluded → wait / no lease.
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no assignable work for role '{role_norm}': all "
|
||||
f"{len(controller_excluded)} candidate(s) were removed by "
|
||||
f"{SKIP_EXCLUDED_BY_CONTROLLER} before ranking; no assignment "
|
||||
"or lease created (#776 AC7)"
|
||||
]
|
||||
else:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}' "
|
||||
f"among {len(ordered)} candidates"
|
||||
f"among {len(ordered)} rankable candidates "
|
||||
f"({len(controller_excluded)} controller-excluded)"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
@@ -389,6 +843,13 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"owner_session_id": owner_session_id,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -435,6 +896,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -558,6 +1025,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -589,14 +1062,32 @@ def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
|
||||
|
||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory).
|
||||
|
||||
#776: reject booleans-as-integers and non-int numbers fail-closed.
|
||||
"""
|
||||
if "number" not in data:
|
||||
raise KeyError("number")
|
||||
number = _require_strict_int(data["number"], field="number")
|
||||
priority_raw = data.get("priority") or 0
|
||||
if isinstance(priority_raw, bool) or not isinstance(priority_raw, (int, float)):
|
||||
# Allow numeric strings only for priority? Keep strict for bools.
|
||||
if isinstance(priority_raw, str) and priority_raw.strip().lstrip("-").isdigit():
|
||||
priority = int(priority_raw)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"priority must be numeric (booleans rejected; "
|
||||
f"got {type(priority_raw).__name__})"
|
||||
)
|
||||
else:
|
||||
priority = int(priority_raw)
|
||||
return WorkCandidate(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
number=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),
|
||||
priority=priority,
|
||||
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")),
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Common anti-stomp preflight for every MCP mutation tool (#604).
|
||||
|
||||
Even with leases, mutation tools need a **shared** preflight that prevents
|
||||
stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks,
|
||||
foreign leases, contaminated approvals, and root-checkout mutations from
|
||||
slipping through.
|
||||
|
||||
This module is the pure assessment core. Callers (MCP mutation entrypoints)
|
||||
gather live facts and pass them in — nothing here performs git, network, or
|
||||
durable-state I/O. Existing happy-path guards remain authoritative; this
|
||||
module composes them into one typed, fail-closed result.
|
||||
|
||||
Required checks (issue #604):
|
||||
|
||||
* repo/org verification
|
||||
* profile/role verification
|
||||
* root checkout clean and not used for mutation (when role requires branches/)
|
||||
* worktree under branches when required
|
||||
* active lease ownership (when lease is required for the mutation)
|
||||
* terminal lock status (when applicable)
|
||||
* expected head SHA (when pinned)
|
||||
* stale runtime status
|
||||
* workflow hash (when a workflow load is required)
|
||||
* source contamination status
|
||||
* no manual state/mtime/source-file bypass
|
||||
|
||||
Failure returns a typed blocker and an exact next action. There is **no**
|
||||
agent-facing bypass flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import author_mutation_worktree
|
||||
import create_issue_bootstrap
|
||||
import master_parity_gate
|
||||
import remote_repo_guard
|
||||
import root_checkout_guard
|
||||
import stable_branch_push_guard
|
||||
|
||||
# ── public constants ──────────────────────────────────────────────────────────
|
||||
|
||||
# Typed blocker kinds returned in the structured result.
|
||||
BLOCKER_WRONG_REPO = "wrong_repo"
|
||||
BLOCKER_WRONG_ROLE = "wrong_role"
|
||||
BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation"
|
||||
BLOCKER_WRONG_WORKTREE = "wrong_worktree"
|
||||
BLOCKER_FOREIGN_LEASE = "foreign_lease"
|
||||
BLOCKER_TERMINAL_LOCK = "terminal_lock"
|
||||
BLOCKER_HEAD_SHA = "head_sha_mismatch"
|
||||
BLOCKER_STALE_RUNTIME = "stale_runtime"
|
||||
BLOCKER_WORKFLOW_HASH = "workflow_hash"
|
||||
BLOCKER_SOURCE_CONTAMINATION = "source_contamination"
|
||||
BLOCKER_MANUAL_BYPASS = "manual_bypass"
|
||||
|
||||
BLOCKER_KINDS = frozenset({
|
||||
BLOCKER_WRONG_REPO,
|
||||
BLOCKER_WRONG_ROLE,
|
||||
BLOCKER_ROOT_CHECKOUT,
|
||||
BLOCKER_WRONG_WORKTREE,
|
||||
BLOCKER_FOREIGN_LEASE,
|
||||
BLOCKER_TERMINAL_LOCK,
|
||||
BLOCKER_HEAD_SHA,
|
||||
BLOCKER_STALE_RUNTIME,
|
||||
BLOCKER_WORKFLOW_HASH,
|
||||
BLOCKER_SOURCE_CONTAMINATION,
|
||||
BLOCKER_MANUAL_BYPASS,
|
||||
})
|
||||
|
||||
# Mutation tasks that must invoke the shared anti-stomp preflight before
|
||||
# acting (issue #604 AC1). Every member must appear as a live task= kwarg
|
||||
# to verify_preflight_purity / _run_anti_stomp_preflight (or the review
|
||||
# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if
|
||||
# a declared task is not wired.
|
||||
MUTATION_TASKS = frozenset({
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"close_issue",
|
||||
"edit_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
"create_label",
|
||||
"create_pr",
|
||||
"close_pr",
|
||||
"edit_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"cleanup_stale_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_close_superseded_pr",
|
||||
"post_heartbeat",
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"merge_pr",
|
||||
})
|
||||
|
||||
# Intentionally excluded from MUTATION_TASKS. Each has a dedicated
|
||||
# fail-closed gate that preserves decision-lock ownership, workflow-hash,
|
||||
# head-SHA, and repository consistency. Do not re-add without either
|
||||
# wiring shared preflight or updating this rationale (#604 Blocker B).
|
||||
DEDICATED_GATE_MUTATIONS: dict[str, str] = {
|
||||
"mark_final_review_decision": (
|
||||
"Local review-decision lock only. Enforced by session lock ownership, "
|
||||
"workflow-hash, expected head SHA, PR work lease, eligibility, and "
|
||||
"terminal-lock gates. No Gitea review POST; shared anti-stomp would "
|
||||
"duplicate without side-effect-ordering value."
|
||||
),
|
||||
"save_review_draft": (
|
||||
"Local session-state draft save with live PR head/base consistency "
|
||||
"checks. Not a Gitea review/merge mutation; dedicated head-SHA and "
|
||||
"worktree resolution gates apply."
|
||||
),
|
||||
"resume_review_draft": (
|
||||
"Live-state resume with terminal lock, lease ownership, head/base SHA, "
|
||||
"and parity checks. When submit=True, delegates to gitea_submit_pr_review "
|
||||
"which runs shared anti-stomp before the Gitea review POST."
|
||||
),
|
||||
"cleanup_stale_review_decision_lock": (
|
||||
"Moot-lock cleanup with identity match, live PR merged/closed proof, "
|
||||
"and reviewer capability gate (#594). Distinct from shared anti-stomp."
|
||||
),
|
||||
"gitea_cleanup_stale_review_decision_lock": (
|
||||
"Alias of cleanup_stale_review_decision_lock; dedicated #594 path."
|
||||
),
|
||||
"heartbeat_reviewer_pr_lease": (
|
||||
"Lease heartbeat posts via verify_preflight_purity(task='review_pr') "
|
||||
"plus in-session lease ownership checks; not a separate mutation class."
|
||||
),
|
||||
"release_reviewer_pr_lease": (
|
||||
"Lease release uses workspace binding + ownership checks; posts only "
|
||||
"when the session owns the active lease."
|
||||
),
|
||||
}
|
||||
|
||||
# Roles that must mutate from a branches/ worktree (not the control checkout).
|
||||
_BRANCHES_REQUIRED_ROLES = frozenset({"author"})
|
||||
|
||||
# Reviewer/merger mutations that require an owned lease + head pinning when
|
||||
# the caller supplies those facts.
|
||||
_LEASE_AWARE_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"merge_pr",
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
})
|
||||
|
||||
_NEXT_ACTIONS: dict[str, str] = {
|
||||
BLOCKER_WRONG_REPO: (
|
||||
"Pass explicit org= and repo= matching the local git remote "
|
||||
"(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry."
|
||||
),
|
||||
BLOCKER_WRONG_ROLE: (
|
||||
"Call gitea_resolve_task_capability, switch to the required "
|
||||
"namespace/profile, re-verify with gitea_whoami, then retry."
|
||||
),
|
||||
BLOCKER_ROOT_CHECKOUT: (
|
||||
"Leave the root control checkout on clean master; create or switch "
|
||||
"to a session-owned worktree under branches/ and retry the mutation "
|
||||
"with worktree_path set."
|
||||
),
|
||||
BLOCKER_WRONG_WORKTREE: (
|
||||
"Create or bind a session-owned worktree under branches/, set "
|
||||
"worktree_path (or GITEA_ACTIVE_WORKTREE), and retry."
|
||||
),
|
||||
BLOCKER_FOREIGN_LEASE: (
|
||||
"Stop. Do not stomp a foreign lease. Wait for expiry, request "
|
||||
"takeover through the sanctioned path, or hand off to the owner."
|
||||
),
|
||||
BLOCKER_TERMINAL_LOCK: (
|
||||
"Stop. A terminal review-decision lock is active for this head. "
|
||||
"Do not re-approve/merge; follow the #332/#620 recovery path."
|
||||
),
|
||||
BLOCKER_HEAD_SHA: (
|
||||
"Re-fetch the live PR head with gitea_view_pr, re-pin "
|
||||
"expected_head_sha to the current head, re-validate, then retry."
|
||||
),
|
||||
BLOCKER_STALE_RUNTIME: (
|
||||
"Restart the Gitea MCP server so it reloads master's capability "
|
||||
"gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry."
|
||||
),
|
||||
BLOCKER_WORKFLOW_HASH: (
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory (no approve/merge replay)."
|
||||
),
|
||||
BLOCKER_SOURCE_CONTAMINATION: (
|
||||
"Stop. Session is source-contaminated. Hand off to a reconciler for "
|
||||
"audit/clear; do not clear markers by deleting session-state files."
|
||||
),
|
||||
BLOCKER_MANUAL_BYPASS: (
|
||||
"Stop. Manual state/mtime/source-file bypass is forbidden. Use "
|
||||
"sanctioned MCP tools only; never delete or rewrite session-state "
|
||||
"or lock files by hand."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def is_mutation_task(task: str | None) -> bool:
|
||||
"""True when *task* is in the #604 mutation set (normalized)."""
|
||||
name = (task or "").strip().lower().removeprefix("gitea_")
|
||||
if not name:
|
||||
return False
|
||||
if name in MUTATION_TASKS:
|
||||
return True
|
||||
# Accept gitea_ prefixed membership for aliases already in the set.
|
||||
return f"gitea_{name}" in MUTATION_TASKS
|
||||
|
||||
|
||||
def roles_compatible(active_role: str | None, required_role: str | None) -> bool:
|
||||
"""Whether *active_role* may perform a task that maps to *required_role*.
|
||||
|
||||
Exact match always passes. Reconcilers may run author-class mutations
|
||||
(comment/close/cleanup) that the capability map stamps as ``author`` —
|
||||
matching the long-standing reconciler exemption for control-checkout
|
||||
work. No other cross-role substitutions are allowed.
|
||||
|
||||
Capability-authorized multi-role tasks (e.g. reviewer holding
|
||||
``gitea.issue.comment`` for ``comment_issue``) are handled by
|
||||
:func:`authorization_compatible`, not by expanding this role matrix.
|
||||
"""
|
||||
active = (active_role or "").strip().lower()
|
||||
required = (required_role or "").strip().lower()
|
||||
if not active or not required:
|
||||
return True
|
||||
if active == required:
|
||||
return True
|
||||
if active == "reconciler" and required in {"author", "reconciler"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def authorization_compatible(
|
||||
active_role: str | None,
|
||||
required_role: str | None,
|
||||
*,
|
||||
required_permission: str | None = None,
|
||||
allowed_operations: Any = None,
|
||||
) -> bool:
|
||||
"""Whether the active session may run a task given role *and* capability.
|
||||
|
||||
Order:
|
||||
|
||||
1. :func:`roles_compatible` (exact role or narrow reconciler→author).
|
||||
2. Capability possession: when *required_permission* is non-empty and
|
||||
present in *allowed_operations*, allow even if the nominal task role
|
||||
differs (e.g. reviewer/merger with ``gitea.issue.comment`` on
|
||||
``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` /
|
||||
``lock_issue``).
|
||||
|
||||
Fail closed otherwise. This is **not** a broad reviewer→author or
|
||||
merger→author role rewrite: without the specific permission the check
|
||||
still denies. Missing *allowed_operations* when a permission is required
|
||||
also fails closed (callers must supply the live profile op list).
|
||||
"""
|
||||
if roles_compatible(active_role, required_role):
|
||||
return True
|
||||
perm = (required_permission or "").strip()
|
||||
if not perm:
|
||||
return False
|
||||
if allowed_operations is None:
|
||||
return False
|
||||
try:
|
||||
ops = {str(o).strip() for o in allowed_operations if o is not None}
|
||||
except TypeError:
|
||||
return False
|
||||
return perm in ops
|
||||
|
||||
|
||||
def _blocker(
|
||||
kind: str,
|
||||
reasons: list[str],
|
||||
*,
|
||||
exact_next_action: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in BLOCKER_KINDS:
|
||||
raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}")
|
||||
return {
|
||||
"kind": kind,
|
||||
"reasons": list(reasons),
|
||||
"exact_next_action": exact_next_action or _NEXT_ACTIONS[kind],
|
||||
"detail": dict(detail or {}),
|
||||
}
|
||||
|
||||
|
||||
def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": True,
|
||||
"block": False,
|
||||
"blockers": [],
|
||||
"reasons": [],
|
||||
"exact_next_action": "proceed",
|
||||
"blocker_kind": None,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _blocked_result(
|
||||
blockers: list[dict[str, Any]],
|
||||
checks: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
primary = blockers[0]
|
||||
all_reasons: list[str] = []
|
||||
for b in blockers:
|
||||
for r in b.get("reasons") or []:
|
||||
if r not in all_reasons:
|
||||
all_reasons.append(r)
|
||||
return {
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"blockers": blockers,
|
||||
"reasons": all_reasons,
|
||||
"exact_next_action": primary["exact_next_action"],
|
||||
"blocker_kind": primary["kind"],
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def assess_anti_stomp_preflight(
|
||||
*,
|
||||
task: str | None = None,
|
||||
# repo/org
|
||||
remote: str | None = None,
|
||||
resolved_org: str | None = None,
|
||||
resolved_repo: str | None = None,
|
||||
local_remote_url: str | None = None,
|
||||
org_explicit: bool = False,
|
||||
repo_explicit: bool = False,
|
||||
check_repo: bool = True,
|
||||
# profile/role + capability
|
||||
profile_name: str | None = None,
|
||||
profile_role: str | None = None,
|
||||
required_role: str | None = None,
|
||||
required_permission: str | None = None,
|
||||
allowed_operations: Any = None,
|
||||
check_role: bool = True,
|
||||
# root checkout + worktree
|
||||
workspace_path: str | None = None,
|
||||
project_root: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
root_head_sha: str | None = None,
|
||||
root_porcelain: str | None = None,
|
||||
remote_master_sha: str | None = None,
|
||||
check_root_checkout: bool = True,
|
||||
check_worktree: bool = True,
|
||||
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||
# stale runtime (master parity)
|
||||
startup_head: str | None = None,
|
||||
current_code_head: str | None = None,
|
||||
check_stale_runtime: bool = True,
|
||||
# lease ownership
|
||||
lease_required: bool = False,
|
||||
foreign_lease: bool | None = None,
|
||||
lease_owner_session: str | None = None,
|
||||
active_session_id: str | None = None,
|
||||
lease_owner_identity: str | None = None,
|
||||
active_identity: str | None = None,
|
||||
lease_reasons: list[str] | None = None,
|
||||
# terminal lock
|
||||
terminal_lock_blocks: bool | None = None,
|
||||
terminal_lock_reasons: list[str] | None = None,
|
||||
# expected head SHA
|
||||
require_head_sha: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
live_head_sha: str | None = None,
|
||||
# workflow hash
|
||||
workflow_hash_valid: bool | None = None,
|
||||
workflow_hash_reasons: list[str] | None = None,
|
||||
# source contamination (stable-branch push / approval contamination)
|
||||
source_contaminated: bool | None = None,
|
||||
contamination_reasons: list[str] | None = None,
|
||||
# manual bypass
|
||||
manual_bypass_attempted: bool = False,
|
||||
manual_bypass_reasons: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail-closed common anti-stomp assessment (pure).
|
||||
|
||||
Only checks for which facts are supplied (or which are required by the
|
||||
mutation category) are evaluated. Unsupplied optional facts are recorded
|
||||
as ``skipped`` so callers can see coverage without inventing defaults.
|
||||
|
||||
Returns a structured dict with ``allowed``/``block``, typed ``blockers``,
|
||||
aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``.
|
||||
"""
|
||||
task_name = (task or "").strip()
|
||||
role = (profile_role or "").strip().lower() or None
|
||||
req_role = (required_role or "").strip().lower() or None
|
||||
req_perm = (required_permission or "").strip() or None
|
||||
checks: dict[str, Any] = {
|
||||
"task": task_name or None,
|
||||
"profile_name": profile_name,
|
||||
"profile_role": role,
|
||||
"required_role": req_role,
|
||||
"required_permission": req_perm,
|
||||
}
|
||||
blockers: list[dict[str, Any]] = []
|
||||
|
||||
# ── manual bypass (always first; never skippable when attempted) ─────────
|
||||
if manual_bypass_attempted:
|
||||
reasons = list(manual_bypass_reasons or [
|
||||
"manual state/mtime/source-file bypass attempt detected (fail closed)"
|
||||
])
|
||||
blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons))
|
||||
checks["manual_bypass"] = {"block": True, "reasons": reasons}
|
||||
else:
|
||||
checks["manual_bypass"] = {"block": False, "skipped": False}
|
||||
|
||||
# ── repo/org ─────────────────────────────────────────────────────────────
|
||||
if check_repo and resolved_org is not None and resolved_repo is not None:
|
||||
repo_assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote=remote or "",
|
||||
resolved_org=resolved_org,
|
||||
resolved_repo=resolved_repo,
|
||||
local_remote_url=local_remote_url,
|
||||
org_explicit=org_explicit,
|
||||
repo_explicit=repo_explicit,
|
||||
)
|
||||
checks["repo"] = {
|
||||
"block": bool(repo_assessment.get("block")),
|
||||
"reasons": list(repo_assessment.get("reasons") or []),
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
}
|
||||
if repo_assessment.get("block"):
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_REPO,
|
||||
list(repo_assessment.get("reasons") or ["repo/org mismatch"]),
|
||||
detail={
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
"local_remote_url": local_remote_url,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["repo"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── profile/role + capability ────────────────────────────────────────────
|
||||
# Role match OR possession of the task's required permission (Blocker A).
|
||||
# Reviewer/merger may run issue-comment-class tasks when they hold
|
||||
# gitea.issue.comment; unauthorized escalation without the permission
|
||||
# still fails closed.
|
||||
if check_role and req_role and role:
|
||||
authorized = authorization_compatible(
|
||||
role,
|
||||
req_role,
|
||||
required_permission=req_perm,
|
||||
allowed_operations=allowed_operations,
|
||||
)
|
||||
if not authorized:
|
||||
perm_clause = (
|
||||
f", required_permission='{req_perm}'" if req_perm else ""
|
||||
)
|
||||
reasons = [
|
||||
f"active profile role '{role}' is not authorized for task "
|
||||
f"'{task_name or '(unknown)'}' (required_role='{req_role}'"
|
||||
f"{perm_clause}; profile={profile_name or '(unknown)'})"
|
||||
]
|
||||
checks["role"] = {
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"required_permission": req_perm,
|
||||
"capability_authorized": False,
|
||||
}
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_ROLE,
|
||||
reasons,
|
||||
detail={
|
||||
"profile_name": profile_name,
|
||||
"profile_role": role,
|
||||
"required_role": req_role,
|
||||
"required_permission": req_perm,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["role"] = {
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"required_permission": req_perm,
|
||||
"capability_authorized": bool(
|
||||
req_perm
|
||||
and allowed_operations is not None
|
||||
and req_perm in {
|
||||
str(o).strip() for o in (allowed_operations or [])
|
||||
if o is not None
|
||||
}
|
||||
and not roles_compatible(role, req_role)
|
||||
),
|
||||
}
|
||||
else:
|
||||
checks["role"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── stale runtime (master parity) ────────────────────────────────────────
|
||||
if check_stale_runtime and (
|
||||
startup_head is not None or current_code_head is not None
|
||||
):
|
||||
parity = master_parity_gate.assess_master_parity(
|
||||
{"startup_head": startup_head},
|
||||
current_code_head,
|
||||
)
|
||||
stale_reasons = master_parity_gate.parity_block_reasons(parity)
|
||||
checks["stale_runtime"] = {
|
||||
"block": bool(stale_reasons),
|
||||
"reasons": list(stale_reasons),
|
||||
"startup_head": startup_head,
|
||||
"current_code_head": current_code_head,
|
||||
"stale": bool(parity.get("stale")),
|
||||
}
|
||||
if stale_reasons:
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_STALE_RUNTIME,
|
||||
list(stale_reasons),
|
||||
detail={
|
||||
"startup_head": startup_head,
|
||||
"current_code_head": current_code_head,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["stale_runtime"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── root checkout ────────────────────────────────────────────────────────
|
||||
if (
|
||||
check_root_checkout
|
||||
and workspace_path is not None
|
||||
and project_root is not None
|
||||
and root_porcelain is not None
|
||||
):
|
||||
root_assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||
workspace_path=workspace_path,
|
||||
canonical_repo_root=project_root,
|
||||
current_branch=current_branch,
|
||||
head_sha=root_head_sha,
|
||||
porcelain_status=root_porcelain,
|
||||
remote_master_sha=remote_master_sha,
|
||||
resolved_role=req_role or role,
|
||||
actual_role=role,
|
||||
)
|
||||
checks["root_checkout"] = {
|
||||
"block": bool(root_assessment.get("block")),
|
||||
"reasons": list(root_assessment.get("reasons") or []),
|
||||
}
|
||||
if root_assessment.get("block"):
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_ROOT_CHECKOUT,
|
||||
list(root_assessment.get("reasons") or [
|
||||
"control checkout is not clean master"
|
||||
]),
|
||||
detail={
|
||||
"workspace_path": workspace_path,
|
||||
"project_root": project_root,
|
||||
"current_branch": current_branch,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["root_checkout"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── worktree under branches (author) ─────────────────────────────────────
|
||||
worktree_role = role or req_role
|
||||
if (
|
||||
check_worktree
|
||||
and workspace_path is not None
|
||||
and project_root is not None
|
||||
and worktree_role in _BRANCHES_REQUIRED_ROLES
|
||||
):
|
||||
wt = author_mutation_worktree.assess_author_mutation_worktree(
|
||||
workspace_path=workspace_path,
|
||||
project_root=project_root,
|
||||
current_branch=current_branch,
|
||||
)
|
||||
# #757: the #274 guard consults the server-derived create_issue
|
||||
# bootstrap before blocking the canonical control checkout. Route this
|
||||
# guard's decision through the *same* predicate on the *same*
|
||||
# assessment so the two cannot disagree about identical evidence.
|
||||
# Only the wrong-worktree verdict is waived; every other check in this
|
||||
# assessment (root checkout, repo, role, stale runtime, lease, ...) is
|
||||
# evaluated independently and still applies.
|
||||
bootstrap_waived = wt.get("block") and (
|
||||
create_issue_bootstrap.bootstrap_permits_control_checkout(
|
||||
create_issue_bootstrap_assessment,
|
||||
task=task_name,
|
||||
workspace_path=workspace_path,
|
||||
canonical_repo_root=project_root,
|
||||
)
|
||||
)
|
||||
checks["worktree"] = {
|
||||
"block": bool(wt.get("block")) and not bootstrap_waived,
|
||||
"reasons": list(wt.get("reasons") or []),
|
||||
"under_branches": wt.get("under_branches"),
|
||||
"create_issue_bootstrap_waived": bool(bootstrap_waived),
|
||||
}
|
||||
if wt.get("block") and not bootstrap_waived:
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_WORKTREE,
|
||||
list(wt.get("reasons") or [
|
||||
"mutation worktree is not under branches/"
|
||||
]),
|
||||
detail={
|
||||
"workspace_path": workspace_path,
|
||||
"project_root": project_root,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["worktree"] = {
|
||||
"block": False,
|
||||
"skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES
|
||||
or workspace_path is None,
|
||||
}
|
||||
|
||||
# ── foreign lease ────────────────────────────────────────────────────────
|
||||
lease_needed = lease_required or (
|
||||
task_name.removeprefix("gitea_") in {
|
||||
t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS
|
||||
}
|
||||
and foreign_lease is not None
|
||||
)
|
||||
if lease_needed or foreign_lease is True:
|
||||
is_foreign = bool(foreign_lease)
|
||||
if foreign_lease is None and lease_required:
|
||||
# Ownership must be proven when lease_required; missing ownership
|
||||
# facts fail closed.
|
||||
owner_ok = (
|
||||
(not lease_owner_session and not active_session_id)
|
||||
or (
|
||||
lease_owner_session
|
||||
and active_session_id
|
||||
and lease_owner_session == active_session_id
|
||||
)
|
||||
)
|
||||
identity_ok = (
|
||||
(not lease_owner_identity and not active_identity)
|
||||
or (
|
||||
lease_owner_identity
|
||||
and active_identity
|
||||
and lease_owner_identity == active_identity
|
||||
)
|
||||
)
|
||||
if not owner_ok or not identity_ok:
|
||||
is_foreign = True
|
||||
reasons = list(lease_reasons or [])
|
||||
if is_foreign and not reasons:
|
||||
reasons = [
|
||||
"active lease is owned by a foreign session or identity "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["lease"] = {
|
||||
"block": is_foreign,
|
||||
"reasons": reasons if is_foreign else [],
|
||||
"lease_owner_session": lease_owner_session,
|
||||
"active_session_id": active_session_id,
|
||||
}
|
||||
if is_foreign:
|
||||
blockers.append(
|
||||
_blocker(BLOCKER_FOREIGN_LEASE, reasons)
|
||||
)
|
||||
else:
|
||||
checks["lease"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── terminal lock ────────────────────────────────────────────────────────
|
||||
if terminal_lock_blocks is not None:
|
||||
reasons = list(terminal_lock_reasons or [])
|
||||
if terminal_lock_blocks and not reasons:
|
||||
reasons = [
|
||||
"terminal review-decision lock is active for this head "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["terminal_lock"] = {
|
||||
"block": bool(terminal_lock_blocks),
|
||||
"reasons": reasons if terminal_lock_blocks else [],
|
||||
}
|
||||
if terminal_lock_blocks:
|
||||
blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons))
|
||||
else:
|
||||
checks["terminal_lock"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── expected head SHA ────────────────────────────────────────────────────
|
||||
if require_head_sha or (
|
||||
expected_head_sha is not None and live_head_sha is not None
|
||||
):
|
||||
exp = (expected_head_sha or "").strip().lower()
|
||||
live = (live_head_sha or "").strip().lower()
|
||||
mismatch = False
|
||||
reasons: list[str] = []
|
||||
if require_head_sha and not exp:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
"expected_head_sha is required before this mutation "
|
||||
"(anti-stomp fail closed)"
|
||||
)
|
||||
elif exp and live and exp != live:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
f"expected_head_sha '{exp}' does not match live PR head "
|
||||
f"'{live}' (anti-stomp fail closed; stale prompt data)"
|
||||
)
|
||||
elif require_head_sha and exp and not live:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
"live PR head SHA could not be determined to corroborate "
|
||||
"expected_head_sha (anti-stomp fail closed)"
|
||||
)
|
||||
checks["head_sha"] = {
|
||||
"block": mismatch,
|
||||
"reasons": reasons,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"live_head_sha": live_head_sha,
|
||||
}
|
||||
if mismatch:
|
||||
blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons))
|
||||
else:
|
||||
checks["head_sha"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── workflow hash ────────────────────────────────────────────────────────
|
||||
if workflow_hash_valid is not None:
|
||||
reasons = list(workflow_hash_reasons or [])
|
||||
if not workflow_hash_valid and not reasons:
|
||||
reasons = [
|
||||
"workflow load proof missing or hash stale "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["workflow_hash"] = {
|
||||
"block": not bool(workflow_hash_valid),
|
||||
"reasons": reasons if not workflow_hash_valid else [],
|
||||
}
|
||||
if not workflow_hash_valid:
|
||||
blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons))
|
||||
else:
|
||||
checks["workflow_hash"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── source contamination ─────────────────────────────────────────────────
|
||||
if source_contaminated is not None:
|
||||
reasons = list(contamination_reasons or [])
|
||||
if source_contaminated and not reasons:
|
||||
reasons = [
|
||||
"session is source-contaminated (stable-branch push or "
|
||||
"contaminated approval path); anti-stomp fail closed"
|
||||
]
|
||||
checks["source_contamination"] = {
|
||||
"block": bool(source_contaminated),
|
||||
"reasons": reasons if source_contaminated else [],
|
||||
}
|
||||
if source_contaminated:
|
||||
blockers.append(
|
||||
_blocker(BLOCKER_SOURCE_CONTAMINATION, reasons)
|
||||
)
|
||||
else:
|
||||
checks["source_contamination"] = {"block": False, "skipped": True}
|
||||
|
||||
if blockers:
|
||||
return _blocked_result(blockers, checks)
|
||||
return _allowed_result(checks)
|
||||
|
||||
|
||||
def format_anti_stomp_error(assessment: dict[str, Any]) -> str:
|
||||
"""Single RuntimeError message for MCP mutation gates."""
|
||||
kind = assessment.get("blocker_kind") or "unknown"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons")
|
||||
or ["anti-stomp preflight blocked the mutation"]
|
||||
)
|
||||
next_action = assessment.get("exact_next_action") or "stop and diagnose"
|
||||
return (
|
||||
f"Anti-stomp preflight (#604) blocked mutation "
|
||||
f"[{kind}]: {reasons}. "
|
||||
f"exact_next_action: {next_action}"
|
||||
)
|
||||
|
||||
|
||||
def block_response(
|
||||
assessment: dict[str, Any],
|
||||
**extra_fields: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured tool-return payload for a blocked mutation."""
|
||||
payload = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"blocked": True,
|
||||
"anti_stomp": True,
|
||||
"blocker_kind": assessment.get("blocker_kind"),
|
||||
"blockers": list(assessment.get("blockers") or []),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"exact_next_action": assessment.get("exact_next_action"),
|
||||
"checks": assessment.get("checks") or {},
|
||||
}
|
||||
payload.update(extra_fields)
|
||||
return payload
|
||||
|
||||
|
||||
def contamination_from_stable_marker(
|
||||
marker: dict | None,
|
||||
*,
|
||||
task: str | None,
|
||||
actual_role: str | None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Derive source-contamination facts from a #671 durable marker."""
|
||||
if not marker:
|
||||
return False, []
|
||||
gate = stable_branch_push_guard.assess_contamination_gate(
|
||||
marker,
|
||||
task=task,
|
||||
actual_role=actual_role,
|
||||
)
|
||||
if gate.get("block"):
|
||||
return True, list(gate.get("reasons") or [])
|
||||
return False, []
|
||||
+544
-3
@@ -1,7 +1,15 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
project's ``branches/`` directory, never from the stable control checkout.
|
||||
|
||||
#618 durable resolution:
|
||||
- Prefer an explicit validated ``worktree_path`` argument.
|
||||
- Else derive the workspace from the active author issue lock's worktree.
|
||||
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
|
||||
when present and valid.
|
||||
- Author mutations never silently fall back to the control checkout or master.
|
||||
- Missing configured bindings fail closed with a clear operator recovery action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +23,18 @@ AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
|
||||
BOUND_WORKTREE_MISSING_MESSAGE = (
|
||||
"bound worktree missing; operator must recreate or repoint the worktree "
|
||||
"and reconnect"
|
||||
)
|
||||
OPERATOR_RECOVERY_RECREATE_REPOINT = (
|
||||
"Recreate the worktree under branches/ (scripts/worktree-start or "
|
||||
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
|
||||
"to that path (or pass worktree_path on mutation tools), keep the control "
|
||||
"checkout clean on master, then reconnect the author MCP session and re-run."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
"""Resolve the workspace path inspected before author mutations.
|
||||
|
||||
Legacy helper: returns the first non-empty candidate path. Prefer
|
||||
:func:`resolve_durable_author_worktree` for mutation guards (#618).
|
||||
"""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #618 durable author worktree resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _abs_real(path: str) -> str:
|
||||
return os.path.realpath(os.path.abspath((path or "").strip()))
|
||||
|
||||
|
||||
def assess_path_traversal_safety(
|
||||
*,
|
||||
path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed on traversal/symlink escapes outside the target repository.
|
||||
|
||||
Uses ``realpath`` so intermediate symlinks cannot walk outside
|
||||
``canonical_repo_root``. Author mutation workspaces must also land under
|
||||
``branches/`` of that root (enforced separately by the branches-only guard).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
raw = (path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path is empty (fail closed)"],
|
||||
"workspace_path": None,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
if "\x00" in raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path contains a null byte (fail closed)"],
|
||||
"workspace_path": raw,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
# Resolve without requiring existence first: abspath then realpath of parents.
|
||||
abs_path = os.path.abspath(raw)
|
||||
try:
|
||||
real = os.path.realpath(abs_path)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [f"worktree path could not be resolved safely: {exc}"],
|
||||
"workspace_path": abs_path,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
root_norm = _normalize_path(root)
|
||||
real_norm = _normalize_path(real)
|
||||
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
|
||||
reasons.append(
|
||||
f"worktree path '{real}' escapes canonical repository root '{root}' "
|
||||
"(traversal/symlink safety, fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"workspace_path": real,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
|
||||
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
|
||||
"""Return realpaths registered in ``git worktree list --porcelain``."""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
paths.append(os.path.realpath(raw))
|
||||
return paths
|
||||
|
||||
|
||||
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
|
||||
"""True/False when inventory is available; None when git inventory fails.
|
||||
|
||||
An empty inventory with a working git root is treated as inconclusive
|
||||
(``None``) so unit tests and partial sandboxes are not false-negative
|
||||
blocked when ``git worktree list`` is mocked/unavailable.
|
||||
"""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
inventory: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
inventory.append(os.path.realpath(raw))
|
||||
if not inventory:
|
||||
return None
|
||||
return os.path.realpath(path) in inventory
|
||||
|
||||
|
||||
def assess_bound_worktree_existence(
|
||||
*,
|
||||
configured_path: str,
|
||||
binding_source: str,
|
||||
canonical_repo_root: str | None = None,
|
||||
role_kind: str = "author",
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
|
||||
raw = (configured_path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": None,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"configured_path": None,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
try:
|
||||
real = _abs_real(raw)
|
||||
except OSError:
|
||||
real = os.path.abspath(raw)
|
||||
|
||||
path_exists = os.path.isdir(real)
|
||||
in_list: bool | None = None
|
||||
inspected_git_root: str | None = None
|
||||
root = (canonical_repo_root or "").strip()
|
||||
if root:
|
||||
in_list = path_in_git_worktree_list(real, root) if path_exists else False
|
||||
if path_exists:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", real, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
|
||||
if path_exists:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": [],
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
reasons = [
|
||||
BOUND_WORKTREE_MISSING_MESSAGE,
|
||||
(
|
||||
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
|
||||
f"points to '{real}' which does not exist on disk"
|
||||
),
|
||||
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
|
||||
]
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": False if in_list is not None else False,
|
||||
"inspected_git_root": None,
|
||||
"reasons": reasons,
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
|
||||
|
||||
def format_bound_worktree_missing_error(assessment: dict) -> str:
|
||||
"""Canonical operator-facing message for a missing author worktree binding."""
|
||||
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
|
||||
source = (
|
||||
assessment.get("binding_source")
|
||||
or assessment.get("workspace_binding_source")
|
||||
or "unknown binding"
|
||||
)
|
||||
path = (
|
||||
assessment.get("configured_path")
|
||||
or assessment.get("workspace_path")
|
||||
or "(unknown)"
|
||||
)
|
||||
return (
|
||||
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
|
||||
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
|
||||
def assess_lock_worktree_ownership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
session_lock_worktree: str | None,
|
||||
) -> dict:
|
||||
"""When a live lock records a worktree, mutation workspace must match it."""
|
||||
locked = (session_lock_worktree or "").strip()
|
||||
if not locked:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
|
||||
"lock_worktree_path": None,
|
||||
}
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
locked_real = os.path.realpath(locked)
|
||||
if workspace != locked_real:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"active author issue lock worktree '{locked_real}' does not match "
|
||||
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
|
||||
],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
|
||||
|
||||
def resolve_durable_author_worktree(
|
||||
*,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
canonical_repo_root: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
validate: bool = True,
|
||||
) -> dict:
|
||||
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
|
||||
|
||||
Candidate priority:
|
||||
1. explicit ``worktree_path`` argument
|
||||
2. ``worktree`` argument
|
||||
3. ``GITEA_ACTIVE_WORKTREE``
|
||||
4. ``GITEA_AUTHOR_WORKTREE``
|
||||
5. active author issue lock ``worktree_path``
|
||||
6. process project root **only** when it is already under ``branches/``
|
||||
|
||||
Configured bindings that point at a missing path fail closed immediately
|
||||
(no demotion to the control checkout). Validation (when *validate*) covers
|
||||
existence, traversal/symlink safety, repository identity, branches/
|
||||
containment, and lock ownership.
|
||||
"""
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
canonical = os.path.realpath(canonical_repo_root or process_root)
|
||||
reasons: list[str] = []
|
||||
role = "author"
|
||||
|
||||
candidates: list[tuple[str | None, str, bool]] = [
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
|
||||
(session_lock_worktree, "active author issue lock worktree", False),
|
||||
]
|
||||
|
||||
selected_path: str | None = None
|
||||
selected_source: str | None = None
|
||||
existence: dict | None = None
|
||||
|
||||
for candidate, source, _configured in candidates:
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
real = _abs_real(text)
|
||||
except OSError:
|
||||
real = os.path.abspath(text)
|
||||
|
||||
existence = assess_bound_worktree_existence(
|
||||
configured_path=real,
|
||||
binding_source=source,
|
||||
canonical_repo_root=canonical,
|
||||
role_kind=role,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
if existence["block"]:
|
||||
# Missing configured binding: fail closed, never fall back (#618).
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": real,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": existence.get("in_git_worktree_list"),
|
||||
"inspected_git_root": None,
|
||||
"reasons": list(existence.get("reasons") or []),
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
selected_path = real
|
||||
selected_source = source
|
||||
break
|
||||
|
||||
if selected_path is None:
|
||||
# No explicit/env/lock binding. Allow process root only when it is a
|
||||
# branches/ worktree (MCP launched from the task worktree). Never
|
||||
# silently bind the stable control checkout.
|
||||
if is_path_under_branches(process_root, canonical):
|
||||
selected_path = process_root
|
||||
selected_source = "MCP process root under branches/ (session-owned)"
|
||||
else:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": process_root,
|
||||
"workspace_binding_source": "no author worktree binding",
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": os.path.isdir(process_root),
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"author mutation requires an explicit validated worktree_path "
|
||||
"or a worktree derived from the active author issue lock; "
|
||||
"silent fallback to the control checkout/master is forbidden (#618)"
|
||||
],
|
||||
"blocker_kind": "author_worktree_unbound_control_checkout",
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
workspace = selected_path
|
||||
source = selected_source or "unknown"
|
||||
path_exists = os.path.isdir(workspace)
|
||||
inspected_git_root: str | None = None
|
||||
in_list: bool | None = None
|
||||
|
||||
if not validate:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
# Traversal / symlink safety
|
||||
safety = assess_path_traversal_safety(
|
||||
path=workspace, canonical_repo_root=canonical
|
||||
)
|
||||
if safety["block"]:
|
||||
reasons.extend(safety["reasons"])
|
||||
else:
|
||||
workspace = safety["workspace_path"] or workspace
|
||||
|
||||
# Existence + git inventory
|
||||
if not path_exists:
|
||||
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
reasons.append(f"resolved worktree '{workspace}' does not exist")
|
||||
else:
|
||||
in_list = path_in_git_worktree_list(workspace, canonical)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
if in_list is False:
|
||||
# Only hard-fail when inventory was obtained and the path is absent.
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' is not listed in git worktree list for "
|
||||
f"'{canonical}' (fail closed)"
|
||||
)
|
||||
|
||||
# Repository identity
|
||||
if path_exists:
|
||||
membership = assess_workspace_repo_membership(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=canonical,
|
||||
)
|
||||
if membership["block"]:
|
||||
reasons.extend(membership["reasons"])
|
||||
|
||||
# branches/ containment
|
||||
branches = assess_author_mutation_worktree(
|
||||
workspace_path=workspace,
|
||||
project_root=canonical,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
|
||||
# Lock ownership (when a lock worktree is recorded)
|
||||
lock_own = assess_lock_worktree_ownership(
|
||||
workspace_path=workspace,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
)
|
||||
if lock_own["block"]:
|
||||
reasons.extend(lock_own["reasons"])
|
||||
|
||||
# Forbid resolved control checkout even if somehow selected
|
||||
if workspace == canonical or workspace == process_root:
|
||||
if not is_path_under_branches(workspace, canonical):
|
||||
if not any("control checkout" in r for r in reasons):
|
||||
reasons.append(
|
||||
"author mutation blocked: resolved workspace is the stable "
|
||||
"control checkout; silent fallback forbidden (#618)"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": bound_missing,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": reasons,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
|
||||
"author_worktree_validation_failed" if block else None
|
||||
),
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
|
||||
def format_durable_author_worktree_error(assessment: dict) -> str:
|
||||
"""Format fail-closed error for durable author worktree resolution."""
|
||||
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
|
||||
return format_bound_worktree_missing_error(assessment)
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
source = assessment.get("workspace_binding_source") or "unknown"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["author worktree resolution failed"]
|
||||
)
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
return (
|
||||
f"Durable author worktree resolution blocked (#618): {reasons}. "
|
||||
f"workspace: {workspace}; binding_source: {source}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Immutable canonical repository root for cross-repository namespaces (#706).
|
||||
|
||||
The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from
|
||||
the *install checkout* the server script lives in
|
||||
(``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace
|
||||
that runs the same server script against an *external* repository (e.g.
|
||||
``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the
|
||||
branches-only / worktree-membership guards (#274) compared the task workspace
|
||||
against the Gitea-Tools ``.git`` directory, which it can never belong to.
|
||||
|
||||
This module separates two distinct concepts:
|
||||
|
||||
* the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and
|
||||
* the namespace-scoped **canonical repository root** — the working root of the
|
||||
repository whose issues/PRs the namespace mutates.
|
||||
|
||||
The canonical repository root is configured per namespace (profile field or an
|
||||
environment variable, typically set alongside the namespace ``cwd`` in the MCP
|
||||
config). It is validated (existence, git identity, git common-directory
|
||||
membership) and pinned immutably into the session context so a later call cannot
|
||||
forge or swap it. When *no* binding is configured the single-repo default is
|
||||
preserved unchanged: the canonical root is derived from the process checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Mapping
|
||||
|
||||
import remote_repo_guard
|
||||
|
||||
# Namespace-scoped override, typically exported next to the server ``cwd`` in the
|
||||
# MCP config for a cross-repository namespace.
|
||||
CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
|
||||
|
||||
# Candidate git remote names probed when deriving repository identity.
|
||||
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
||||
|
||||
|
||||
def configured_canonical_root(
|
||||
profile: Mapping | None,
|
||||
env: Mapping | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(value, source)`` for the declared canonical repository root.
|
||||
|
||||
Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable
|
||||
(namespace-scoped) overrides the profile ``canonical_repository_root``
|
||||
field. Blank values are treated as unset. Returns ``(None, None)`` when no
|
||||
binding is declared (the single-repo default).
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip()
|
||||
if env_val:
|
||||
return env_val, f"{CANONICAL_ROOT_ENV} environment variable"
|
||||
if profile:
|
||||
prof_val = (profile.get("canonical_repository_root") or "").strip()
|
||||
if prof_val:
|
||||
return prof_val, "profile canonical_repository_root"
|
||||
return None, None
|
||||
|
||||
|
||||
def resolve_repo_toplevel(path: str) -> str | None:
|
||||
"""Realpath of the git working-tree top level for *path*, or None."""
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", text, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
top = (res.stdout or "").strip()
|
||||
return os.path.realpath(top) if top else None
|
||||
|
||||
|
||||
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
|
||||
"""``owner/repository`` derived from a git remote configured at *path*.
|
||||
|
||||
Tries the caller-named remote first, then a small set of known remote names,
|
||||
then whatever remote the repository actually has. Returns None when no remote
|
||||
URL is parseable (identity cannot be proven).
|
||||
"""
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
ordered: list[str] = []
|
||||
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
||||
clean = (name or "").strip()
|
||||
if clean and clean not in ordered:
|
||||
ordered.append(clean)
|
||||
|
||||
try:
|
||||
listed = subprocess.run(
|
||||
["git", "-C", text, "remote"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.split()
|
||||
except Exception:
|
||||
listed = []
|
||||
for name in listed:
|
||||
if name and name not in ordered:
|
||||
ordered.append(name)
|
||||
|
||||
for name in ordered:
|
||||
try:
|
||||
url = subprocess.run(
|
||||
["git", "-C", text, "remote", "get-url", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
continue
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
return f"{parsed[0]}/{parsed[1]}"
|
||||
return None
|
||||
|
||||
|
||||
def assess_canonical_repository_root(
|
||||
*,
|
||||
configured_value: str | None,
|
||||
source: str | None,
|
||||
expected_slug: str | None,
|
||||
process_project_root: str,
|
||||
remote: str | None = None,
|
||||
require_binding: bool = False,
|
||||
) -> dict:
|
||||
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||
|
||||
Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved
|
||||
``canonical_repo_root`` (the value downstream guards must use),
|
||||
``configured`` (whether a cross-repo binding was declared),
|
||||
``resolved_slug`` and ``source``.
|
||||
|
||||
Without a configured binding the single-repo default is preserved: the
|
||||
canonical root is derived from *process_project_root* and never blocks
|
||||
(unless *require_binding* explicitly demands one).
|
||||
|
||||
With a configured binding the path must exist, be a git repository, and —
|
||||
when *expected_slug* is known — carry a matching repository identity. A
|
||||
mismatched or (when *require_binding*) unprovable identity is a forged or
|
||||
conflicting binding and fails closed.
|
||||
"""
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
declared = (configured_value or "").strip()
|
||||
|
||||
if not declared:
|
||||
if require_binding:
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
"no canonical_repository_root configured for a cross-repository "
|
||||
f"namespace; set {CANONICAL_ROOT_ENV} or the profile "
|
||||
"canonical_repository_root field (fail closed)"
|
||||
],
|
||||
configured=False,
|
||||
canonical_repo_root=process_root,
|
||||
resolved_slug=None,
|
||||
source=None,
|
||||
)
|
||||
# Single-repo default: canonical root follows the install checkout.
|
||||
derived = resolve_repo_toplevel(process_root) or process_root
|
||||
return _assessment(
|
||||
proven=True,
|
||||
reasons=[],
|
||||
configured=False,
|
||||
canonical_repo_root=derived,
|
||||
resolved_slug=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
real = os.path.realpath(os.path.abspath(declared))
|
||||
if not os.path.isdir(real):
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
f"configured canonical repository root '{real}' does not exist "
|
||||
"or is not a directory (fail closed)"
|
||||
],
|
||||
configured=True,
|
||||
canonical_repo_root=real,
|
||||
resolved_slug=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
toplevel = resolve_repo_toplevel(real)
|
||||
if not toplevel:
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
f"configured canonical repository root '{real}' is not a git "
|
||||
"repository (fail closed)"
|
||||
],
|
||||
configured=True,
|
||||
canonical_repo_root=real,
|
||||
resolved_slug=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||
reasons: list[str] = []
|
||||
expected = (expected_slug or "").strip() or None
|
||||
if expected:
|
||||
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||
reasons.append(
|
||||
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
||||
f"to repository '{resolved_slug}' but the session is authorized for "
|
||||
f"'{expected}' (forged or conflicting binding, fail closed)"
|
||||
)
|
||||
elif not resolved_slug and require_binding:
|
||||
reasons.append(
|
||||
f"canonical repository root '{toplevel}' has no resolvable git "
|
||||
f"remote identity to confirm authorization for '{expected}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
return _assessment(
|
||||
proven=not reasons,
|
||||
reasons=reasons,
|
||||
configured=True,
|
||||
canonical_repo_root=toplevel,
|
||||
resolved_slug=resolved_slug,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def format_canonical_repository_root_error(assessment: Mapping) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
source = assessment.get("source") or "(unconfigured)"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["unknown canonical repository root violation"]
|
||||
)
|
||||
return (
|
||||
f"Canonical repository root guard (#706): {reasons}. "
|
||||
f"binding source: {source}; canonical repository root: {root}. "
|
||||
"Configure a valid canonical_repository_root for the target repository "
|
||||
"and relaunch; do not point it at the Gitea-Tools install checkout."
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
*,
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
configured: bool,
|
||||
canonical_repo_root: str,
|
||||
resolved_slug: str | None,
|
||||
source: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(reasons),
|
||||
"configured": configured,
|
||||
"canonical_repo_root": canonical_repo_root,
|
||||
"resolved_slug": resolved_slug,
|
||||
"source": source,
|
||||
}
|
||||
+438
-14
@@ -29,7 +29,9 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
import dependency_graph
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
# Assignable work kinds only — raw monitoring incidents are never work items.
|
||||
WORK_KINDS = frozenset({"issue", "pr"})
|
||||
@@ -147,7 +149,41 @@ CREATE TABLE IF NOT EXISTS incident_links (
|
||||
UNIQUE (provider, provider_base_url, provider_org, provider_project, provider_issue_id)
|
||||
);
|
||||
|
||||
-- Durable dependency graph (#784, umbrella #628 scope item 6). Dependencies
|
||||
-- were previously re-parsed per allocation run and discarded; each row here is
|
||||
-- one relationship with its conditions, current state, and evidence. Creating
|
||||
-- the table is itself the v3→v4 migration: additive, idempotent, and it never
|
||||
-- touches the pre-existing tables.
|
||||
CREATE TABLE IF NOT EXISTS dependency_edges (
|
||||
edge_id TEXT PRIMARY KEY,
|
||||
remote TEXT NOT NULL,
|
||||
org TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('issue', 'pr')),
|
||||
source_number INTEGER NOT NULL,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('issue', 'pr')),
|
||||
target_number INTEGER NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
blocking_condition TEXT NOT NULL DEFAULT '',
|
||||
completion_condition TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_observed_at TEXT NOT NULL,
|
||||
UNIQUE (
|
||||
remote, org, repo, source_kind, source_number,
|
||||
target_kind, target_number, edge_type
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_work_status ON leases(work_item_id, status);
|
||||
-- Reverse lookup ("what waits on this target") is the query automatic
|
||||
-- resumption needs, so it gets its own index alongside the forward one.
|
||||
CREATE INDEX IF NOT EXISTS idx_dependency_edges_source
|
||||
ON dependency_edges(remote, org, repo, source_kind, source_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependency_edges_target
|
||||
ON dependency_edges(remote, org, repo, target_kind, target_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_assignments_session ON assignments(session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_gitea ON incident_links(gitea_org, gitea_repo, gitea_issue_number);
|
||||
"""
|
||||
@@ -302,6 +338,7 @@ class ControlPlaneDB:
|
||||
conn.executescript(_SCHEMA_SQL)
|
||||
self._migrate_incident_links_null_scope(conn)
|
||||
self._migrate_lease_lifecycle_columns(conn)
|
||||
self._migrate_session_ownership_columns(conn)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
@@ -492,32 +529,62 @@ class ControlPlaneDB:
|
||||
namespace: str | None = None,
|
||||
pid: int | None = None,
|
||||
status: str = "active",
|
||||
controller_instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Register/refresh a session row.
|
||||
|
||||
*controller_instance_id* (#765) is the stable identity of the
|
||||
controller that owns this session. Session ids are regenerated per
|
||||
invocation, so they cannot express "my own in-progress task"; the
|
||||
controller instance can. It is never overwritten with ``None``, so a
|
||||
heartbeat from a caller that does not supply one cannot erase
|
||||
ownership.
|
||||
"""
|
||||
now = _ts()
|
||||
instance = (controller_instance_id or "").strip() or None
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
if instance is None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?,
|
||||
controller_instance_id = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(
|
||||
role, profile, namespace, pid, now, status,
|
||||
instance, session_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions(
|
||||
session_id, role, profile, namespace, pid,
|
||||
started_at, last_heartbeat_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
started_at, last_heartbeat_at, status,
|
||||
controller_instance_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(session_id, role, profile, namespace, pid, now, now, status),
|
||||
(
|
||||
session_id, role, profile, namespace, pid, now, now,
|
||||
status, instance,
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
@@ -1215,6 +1282,73 @@ class ControlPlaneDB:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}")
|
||||
|
||||
_SESSION_OWNERSHIP_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("controller_instance_id", "TEXT"),
|
||||
)
|
||||
|
||||
def _migrate_session_ownership_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""Add the stable controller identity to sessions (#765).
|
||||
|
||||
Pre-existing rows migrate with ``NULL``. A NULL instance is treated as
|
||||
*unknown ownership* by the allocator and is never silently adopted.
|
||||
"""
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
|
||||
}
|
||||
if not cols:
|
||||
return
|
||||
for name, decl in self._SESSION_OWNERSHIP_COLUMNS:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
|
||||
|
||||
def list_active_claims(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
role: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> dict[tuple[str, int], dict[str, Any]]:
|
||||
"""Map ``(work_kind, work_number)`` to its live claim (#765).
|
||||
|
||||
Only ``active`` leases count as claims; released/expired rows never
|
||||
withhold work. Callers compare the returned ``controller_instance_id``
|
||||
against their own to decide own-task vs foreign-task.
|
||||
"""
|
||||
claims: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
for row in self.list_leases(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
role=role,
|
||||
statuses=("active",),
|
||||
limit=limit,
|
||||
):
|
||||
kind = str(row.get("work_kind") or "").strip().lower()
|
||||
number = row.get("work_number")
|
||||
if not kind or number is None:
|
||||
continue
|
||||
key = (kind, int(number))
|
||||
claim = {
|
||||
"lease_id": row.get("lease_id"),
|
||||
"session_id": row.get("session_id"),
|
||||
"controller_instance_id": row.get("session_controller_instance_id"),
|
||||
"role": row.get("role"),
|
||||
"profile": row.get("session_profile"),
|
||||
"expires_at": row.get("expires_at"),
|
||||
"work_kind": kind,
|
||||
"work_number": int(number),
|
||||
}
|
||||
# Keep the longest-lived claim when duplicates exist.
|
||||
previous = claims.get(key)
|
||||
if previous is None or str(claim["expires_at"] or "") > str(
|
||||
previous["expires_at"] or ""
|
||||
):
|
||||
claims[key] = claim
|
||||
return claims
|
||||
|
||||
def _lease_columns(self, conn: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[1]
|
||||
@@ -1256,7 +1390,8 @@ class ControlPlaneDB:
|
||||
w.number AS work_number, w.state AS work_state,
|
||||
w.current_head_sha AS work_head_sha,
|
||||
s.pid AS session_pid, s.profile AS session_profile,
|
||||
s.status AS session_status
|
||||
s.status AS session_status,
|
||||
s.controller_instance_id AS session_controller_instance_id
|
||||
FROM leases l
|
||||
JOIN work_items w ON w.work_item_id = l.work_item_id
|
||||
LEFT JOIN sessions s ON s.session_id = l.session_id
|
||||
@@ -1749,3 +1884,292 @@ class ControlPlaneDB:
|
||||
f"transferred lease ownership from {owner} to {adopter_session_id}"
|
||||
],
|
||||
}
|
||||
|
||||
# --- Dependency graph (#784, umbrella #628 scope item 6) ----------------
|
||||
|
||||
@staticmethod
|
||||
def _dependency_edge_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
"""Return a stored edge as a plain dict with evidence decoded."""
|
||||
if row is None:
|
||||
return None
|
||||
edge = dict(row)
|
||||
raw = edge.get("evidence")
|
||||
try:
|
||||
edge["evidence"] = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
# A row written by an older/foreign writer must not break reads.
|
||||
edge["evidence"] = {"unparsed": str(raw)}
|
||||
return edge
|
||||
|
||||
def upsert_dependency_edge(
|
||||
self,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
source_kind: str,
|
||||
source_number: int,
|
||||
target_kind: str,
|
||||
target_number: int,
|
||||
edge_type: str,
|
||||
state: str,
|
||||
blocking_condition: str | None = None,
|
||||
completion_condition: str | None = None,
|
||||
evidence: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert or refresh one dependency edge, keyed by its relationship.
|
||||
|
||||
Uniqueness is (scope, source, target, edge_type), so re-observing the
|
||||
same relationship updates one row instead of appending history — the
|
||||
edge is current state, and transitions are recorded as ``events``.
|
||||
|
||||
Edge type, state, and both endpoint kinds are validated fail-closed;
|
||||
an unknown value writes nothing. Evidence is sanitized before storage.
|
||||
"""
|
||||
edge_type_norm = dependency_graph.normalize_edge_type(edge_type)
|
||||
state_norm = dependency_graph.normalize_edge_state(state)
|
||||
source_kind_norm = dependency_graph.normalize_work_kind(source_kind)
|
||||
target_kind_norm = dependency_graph.normalize_work_kind(target_kind)
|
||||
source_no = int(source_number)
|
||||
target_no = int(target_number)
|
||||
if blocking_condition is None or completion_condition is None:
|
||||
defaults = dependency_graph.default_conditions(edge_type_norm)
|
||||
blocking_condition = (
|
||||
defaults[0] if blocking_condition is None else blocking_condition
|
||||
)
|
||||
completion_condition = (
|
||||
defaults[1] if completion_condition is None else completion_condition
|
||||
)
|
||||
evidence_json = json.dumps(
|
||||
dependency_graph.sanitize_evidence(evidence if evidence is not None else {})
|
||||
)
|
||||
now_s = _ts()
|
||||
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"""
|
||||
SELECT * FROM dependency_edges
|
||||
WHERE remote = ? AND org = ? AND repo = ?
|
||||
AND source_kind = ? AND source_number = ?
|
||||
AND target_kind = ? AND target_number = ? AND edge_type = ?
|
||||
""",
|
||||
(
|
||||
remote,
|
||||
org,
|
||||
repo,
|
||||
source_kind_norm,
|
||||
source_no,
|
||||
target_kind_norm,
|
||||
target_no,
|
||||
edge_type_norm,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
edge_id = uuid.uuid4().hex
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dependency_edges(
|
||||
edge_id, remote, org, repo,
|
||||
source_kind, source_number, target_kind, target_number,
|
||||
edge_type, blocking_condition, completion_condition,
|
||||
state, evidence, created_at, updated_at, last_observed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
edge_id,
|
||||
remote,
|
||||
org,
|
||||
repo,
|
||||
source_kind_norm,
|
||||
source_no,
|
||||
target_kind_norm,
|
||||
target_no,
|
||||
edge_type_norm,
|
||||
blocking_condition,
|
||||
completion_condition,
|
||||
state_norm,
|
||||
evidence_json,
|
||||
now_s,
|
||||
now_s,
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
else:
|
||||
edge_id = str(existing["edge_id"])
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE dependency_edges
|
||||
SET blocking_condition = ?, completion_condition = ?,
|
||||
state = ?, evidence = ?, updated_at = ?,
|
||||
last_observed_at = ?
|
||||
WHERE edge_id = ?
|
||||
""",
|
||||
(
|
||||
blocking_condition,
|
||||
completion_condition,
|
||||
state_norm,
|
||||
evidence_json,
|
||||
now_s,
|
||||
now_s,
|
||||
edge_id,
|
||||
),
|
||||
)
|
||||
prior_state = str(existing["state"])
|
||||
if prior_state != state_norm:
|
||||
self._record_edge_transition_conn(
|
||||
conn,
|
||||
edge_id=edge_id,
|
||||
prior_state=prior_state,
|
||||
new_state=state_norm,
|
||||
detail="observed during upsert",
|
||||
now_s=now_s,
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
return self._dependency_edge_row(row) or {}
|
||||
|
||||
@staticmethod
|
||||
def _record_edge_transition_conn(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
edge_id: str,
|
||||
prior_state: str,
|
||||
new_state: str,
|
||||
detail: str,
|
||||
now_s: str,
|
||||
) -> None:
|
||||
"""Append a state transition to the shared ``events`` audit table.
|
||||
|
||||
``work_item_id`` stays NULL: an edge endpoint is a Gitea issue/PR that
|
||||
may never have been assigned, so it has no work_items row to reference.
|
||||
"""
|
||||
message = (
|
||||
f"dependency edge {edge_id} state {prior_state} -> {new_state}"
|
||||
f" ({detail})"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (NULL, 'dependency_edge_state_change', ?, ?)
|
||||
""",
|
||||
(message, now_s),
|
||||
)
|
||||
|
||||
def list_dependency_edges(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
source_kind: str | None = None,
|
||||
source_number: int | None = None,
|
||||
target_kind: str | None = None,
|
||||
target_number: int | None = None,
|
||||
edge_type: str | None = None,
|
||||
state: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return stored edges, filtered.
|
||||
|
||||
Filtering by *target* answers "what is waiting on this work unit",
|
||||
which is the query automatic resumption needs and which body-text
|
||||
parsing could never serve.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if remote:
|
||||
clauses.append("remote = ?")
|
||||
params.append(remote)
|
||||
if org:
|
||||
clauses.append("org = ?")
|
||||
params.append(org)
|
||||
if repo:
|
||||
clauses.append("repo = ?")
|
||||
params.append(repo)
|
||||
if source_kind:
|
||||
clauses.append("source_kind = ?")
|
||||
params.append(dependency_graph.normalize_work_kind(source_kind))
|
||||
if source_number is not None:
|
||||
clauses.append("source_number = ?")
|
||||
params.append(int(source_number))
|
||||
if target_kind:
|
||||
clauses.append("target_kind = ?")
|
||||
params.append(dependency_graph.normalize_work_kind(target_kind))
|
||||
if target_number is not None:
|
||||
clauses.append("target_number = ?")
|
||||
params.append(int(target_number))
|
||||
if edge_type:
|
||||
clauses.append("edge_type = ?")
|
||||
params.append(dependency_graph.normalize_edge_type(edge_type))
|
||||
if state:
|
||||
clauses.append("state = ?")
|
||||
params.append(dependency_graph.normalize_edge_state(state))
|
||||
|
||||
sql = "SELECT * FROM dependency_edges"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY source_number ASC, target_number ASC, edge_type ASC LIMIT ?"
|
||||
params.append(int(limit))
|
||||
|
||||
with self._tx(immediate=False) as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [edge for edge in (self._dependency_edge_row(r) for r in rows) if edge]
|
||||
|
||||
def record_dependency_edge_observation(
|
||||
self,
|
||||
edge_id: str,
|
||||
*,
|
||||
state: str,
|
||||
evidence: Any = None,
|
||||
detail: str = "observation recorded",
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing edge's state and evidence, auditing the change.
|
||||
|
||||
A transition writes an ``events`` row carrying both the prior and the
|
||||
new state, so a later blocked/resume decision can be reconstructed from
|
||||
durable state rather than from a recomputed reason string.
|
||||
"""
|
||||
state_norm = dependency_graph.normalize_edge_state(state)
|
||||
now_s = _ts()
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
raise ControlPlaneError(
|
||||
f"dependency edge '{edge_id}' does not exist (fail closed)"
|
||||
)
|
||||
prior_state = str(existing["state"])
|
||||
if evidence is None:
|
||||
evidence_json = str(existing["evidence"] or "{}")
|
||||
else:
|
||||
evidence_json = json.dumps(
|
||||
dependency_graph.sanitize_evidence(evidence)
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE dependency_edges
|
||||
SET state = ?, evidence = ?, updated_at = ?, last_observed_at = ?
|
||||
WHERE edge_id = ?
|
||||
""",
|
||||
(state_norm, evidence_json, now_s, now_s, edge_id),
|
||||
)
|
||||
if prior_state != state_norm:
|
||||
self._record_edge_transition_conn(
|
||||
conn,
|
||||
edge_id=edge_id,
|
||||
prior_state=prior_state,
|
||||
new_state=state_norm,
|
||||
detail=detail,
|
||||
now_s=now_s,
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
edge = self._dependency_edge_row(row) or {}
|
||||
edge["prior_state"] = prior_state
|
||||
edge["state_changed"] = prior_state != state_norm
|
||||
return edge
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Sanctioned pre-issue bootstrap for ``create_issue`` (#749).
|
||||
|
||||
``gitea_create_issue`` is a pure remote mutation: it creates a tracking issue
|
||||
and writes nothing to the local working tree. The issue-first gate forbids
|
||||
creating ``branches/issue-<N>-*`` before the issue number exists, while the
|
||||
#274 branches-only guard previously demanded that worktree first — a deadlock.
|
||||
|
||||
This module defines a **narrow, phase-scoped** exemption:
|
||||
|
||||
* Only tasks in :data:`CREATE_ISSUE_TASKS` may use it.
|
||||
* Only the **canonical control checkout** may be used (never an arbitrary
|
||||
directory, unrelated worktree, or foreign clone).
|
||||
* The control checkout must be clean, on an accepted base branch, and
|
||||
base-equivalent to live master when a remote tip is known.
|
||||
* Every post-creation author mutation keeps the ordinary ``branches/`` rule.
|
||||
|
||||
The exemption cannot widen: unknown tasks, dirty roots, drifted HEADs, non-base
|
||||
branches, and non-control workspaces fall through to the existing fail-closed
|
||||
guards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from author_mutation_worktree import BASE_BRANCHES, is_path_under_branches
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
CREATE_ISSUE_TASKS = frozenset({"create_issue", "gitea_create_issue"})
|
||||
|
||||
# Satisfiable before an issue number exists — never names issue-<N>.
|
||||
EXACT_NEXT_ACTION_BOOTSTRAP = (
|
||||
"Restore the canonical control checkout to a clean accepted base branch "
|
||||
"(master/main/dev) that matches live master, with no tracked local edits "
|
||||
"and no detached HEAD. Re-resolve the exact create_issue task, then re-run "
|
||||
"gitea_create_issue from that clean control checkout. Do not create "
|
||||
"branches/issue-<N>-* worktrees, dummy directories, or borrow unrelated "
|
||||
"worktrees before the issue exists."
|
||||
)
|
||||
|
||||
EXACT_NEXT_ACTION_POST_CREATE = (
|
||||
"After the issue exists: create a registered worktree under "
|
||||
"branches/issue-<N>-* from clean master, claim/lock the issue, set "
|
||||
"GITEA_AUTHOR_WORKTREE / worktree_path to that path, then continue author "
|
||||
"mutations from the issue-backed worktree only."
|
||||
)
|
||||
|
||||
|
||||
def is_create_issue_task(task: str | None) -> bool:
|
||||
"""True when *task* is the create_issue mutation (or tool alias)."""
|
||||
return (task or "").strip() in CREATE_ISSUE_TASKS
|
||||
|
||||
|
||||
def normalize_sha(value: str | None) -> str | None:
|
||||
"""Normalize a Git object id for comparison, or ``None`` when unknown.
|
||||
|
||||
Whitespace and case are the only permitted variation between two spellings
|
||||
of the same commit; anything else is a different commit. Empty and
|
||||
whitespace-only values normalize to ``None`` so an unknown tip can never
|
||||
compare equal to another unknown tip.
|
||||
"""
|
||||
normalized = (value or "").strip().lower()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def assess_create_issue_bootstrap(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
current_branch: str | None = None,
|
||||
head_sha: str | None = None,
|
||||
porcelain_status: str = "",
|
||||
remote_master_sha: str | None = None,
|
||||
remote_master_sha_error: str | None = None,
|
||||
task: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether create_issue may proceed from the control checkout.
|
||||
|
||||
Returns a structured assessment:
|
||||
|
||||
* ``not_applicable`` — not a create_issue task, or workspace is already a
|
||||
``branches/`` worktree (use ordinary guards).
|
||||
* ``allowed`` — create_issue bootstrap may proceed from this control root.
|
||||
* ``block`` — create_issue was attempted from control checkout but gates
|
||||
failed (dirty, wrong branch, base race, etc.).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(canonical_repo_root or "")
|
||||
workspace = os.path.realpath(workspace_path or root or ".")
|
||||
branch = (current_branch or "").strip()
|
||||
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||
under_branches = is_path_under_branches(workspace, root) if root else False
|
||||
|
||||
if not is_create_issue_task(task):
|
||||
return _result(
|
||||
not_applicable=True,
|
||||
allowed=False,
|
||||
block=False,
|
||||
reasons=["task is not create_issue"],
|
||||
workspace=workspace,
|
||||
root=root,
|
||||
branch=branch,
|
||||
dirty=dirty,
|
||||
under_branches=under_branches,
|
||||
)
|
||||
|
||||
# Registered branches/ worktrees keep the normal path (no bootstrap).
|
||||
if under_branches:
|
||||
return _result(
|
||||
not_applicable=True,
|
||||
allowed=False,
|
||||
block=False,
|
||||
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
||||
workspace=workspace,
|
||||
root=root,
|
||||
branch=branch,
|
||||
dirty=dirty,
|
||||
under_branches=True,
|
||||
)
|
||||
|
||||
# Only the exact canonical control checkout is eligible.
|
||||
if not root or workspace != root:
|
||||
reasons.append(
|
||||
"create_issue bootstrap requires the canonical control checkout; "
|
||||
f"workspace '{workspace}' is not the repository root '{root or '(unknown)'}'"
|
||||
)
|
||||
return _result(
|
||||
not_applicable=False,
|
||||
allowed=False,
|
||||
block=True,
|
||||
reasons=reasons,
|
||||
workspace=workspace,
|
||||
root=root,
|
||||
branch=branch,
|
||||
dirty=dirty,
|
||||
under_branches=False,
|
||||
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||
)
|
||||
|
||||
if dirty:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: control checkout has tracked local "
|
||||
f"edits (dirty files: {', '.join(dirty)})"
|
||||
)
|
||||
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: control checkout is detached HEAD; "
|
||||
"expected an accepted base branch (master/main/dev)"
|
||||
)
|
||||
elif branch not in BASE_BRANCHES:
|
||||
reasons.append(
|
||||
f"create_issue bootstrap blocked: control checkout branch '{branch}' "
|
||||
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
|
||||
)
|
||||
|
||||
# #757 AC3/AC4: base-equivalence must be *proven*, never assumed. An
|
||||
# unknown tip on either side is not evidence of agreement, so a missing
|
||||
# local HEAD, an unresolvable live master, or a resolver failure all block.
|
||||
remote_tip = normalize_sha(remote_master_sha)
|
||||
local_tip = normalize_sha(head_sha)
|
||||
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||
|
||||
if not local_tip:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: control checkout HEAD SHA is "
|
||||
"unknown; base equivalence to live master cannot be proven "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
if resolver_error:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: live master tip could not be "
|
||||
f"resolved ({resolver_error}); base equivalence cannot be proven "
|
||||
"(fail closed)"
|
||||
)
|
||||
elif not remote_tip:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: live master tip is unknown; base "
|
||||
"equivalence to live master cannot be proven (fail closed)"
|
||||
)
|
||||
|
||||
if remote_tip and local_tip and remote_tip != local_tip:
|
||||
reasons.append(
|
||||
"create_issue bootstrap blocked: control checkout HEAD does not match "
|
||||
f"live master (HEAD {local_tip[:12]}, live master {remote_tip[:12]})"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return _result(
|
||||
not_applicable=False,
|
||||
allowed=False,
|
||||
block=True,
|
||||
reasons=reasons,
|
||||
workspace=workspace,
|
||||
root=root,
|
||||
branch=branch or None,
|
||||
dirty=dirty,
|
||||
under_branches=False,
|
||||
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||
local_head_sha=local_tip,
|
||||
remote_master_sha=remote_tip,
|
||||
)
|
||||
|
||||
return _result(
|
||||
not_applicable=False,
|
||||
allowed=True,
|
||||
block=False,
|
||||
reasons=[],
|
||||
workspace=workspace,
|
||||
root=root,
|
||||
branch=branch or None,
|
||||
dirty=dirty,
|
||||
under_branches=False,
|
||||
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
|
||||
bootstrap_path="clean_canonical_control_checkout",
|
||||
local_head_sha=local_tip,
|
||||
remote_master_sha=remote_tip,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_permits_control_checkout(
|
||||
assessment: Any,
|
||||
*,
|
||||
task: str | None,
|
||||
workspace_path: str | None,
|
||||
canonical_repo_root: str | None,
|
||||
) -> bool:
|
||||
"""Single interpretation of a bootstrap assessment (#757).
|
||||
|
||||
Both author-mutation guards — the #274 branches-only enforcer and the #604
|
||||
anti-stomp preflight — route their "may this workspace mutate" decision
|
||||
through this predicate, so the two can never reach opposite conclusions
|
||||
about identical evidence.
|
||||
|
||||
Fail-closed by construction. Every proof obligation must be present and
|
||||
affirmative in *assessment*, and the assessment must describe the very
|
||||
workspace and canonical root being guarded. A missing, malformed, refused,
|
||||
incomplete, or contradictory assessment returns ``False``, which leaves the
|
||||
caller's ordinary block in force.
|
||||
|
||||
``assessment`` is server-derived only: it is produced by
|
||||
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
|
||||
never accepted from an MCP tool argument, so no caller can assert
|
||||
eligibility it has not proven.
|
||||
"""
|
||||
if not isinstance(assessment, dict):
|
||||
return False
|
||||
if not is_create_issue_task(task):
|
||||
return False
|
||||
|
||||
# Positive proof: the assessment must affirmatively allow, with no
|
||||
# competing refusal or not-applicable disposition recorded alongside it.
|
||||
if assessment.get("allowed") is not True:
|
||||
return False
|
||||
if assessment.get("proven") is not True:
|
||||
return False
|
||||
if assessment.get("block") is not False:
|
||||
return False
|
||||
if assessment.get("not_applicable") is not False:
|
||||
return False
|
||||
if assessment.get("reasons"):
|
||||
return False
|
||||
|
||||
# Scope proof: only the create_issue bootstrap, only via the clean
|
||||
# canonical control checkout path.
|
||||
if assessment.get("task_scope") != "create_issue_only":
|
||||
return False
|
||||
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
||||
return False
|
||||
|
||||
# State proof: clean, and not a branches/ worktree (those keep #274).
|
||||
if assessment.get("dirty_files"):
|
||||
return False
|
||||
if assessment.get("under_branches") is not False:
|
||||
return False
|
||||
|
||||
# Base-equivalence proof (#757 AC3/AC4): both tips must be recorded,
|
||||
# nonempty, and equal. Re-derived here rather than trusted from the
|
||||
# assessment's own flag, so a hand-built or truncated assessment cannot
|
||||
# assert agreement it never proved.
|
||||
if assessment.get("base_tips_verified") is not True:
|
||||
return False
|
||||
local_tip = normalize_sha(assessment.get("local_head_sha"))
|
||||
remote_tip = normalize_sha(assessment.get("remote_master_sha"))
|
||||
if not local_tip or not remote_tip or local_tip != remote_tip:
|
||||
return False
|
||||
|
||||
# Binding proof: the assessment must describe *this* workspace and root,
|
||||
# and that workspace must be exactly the canonical control checkout.
|
||||
root = os.path.realpath(canonical_repo_root or "")
|
||||
workspace = os.path.realpath(workspace_path or root or ".")
|
||||
if not root or workspace != root:
|
||||
return False
|
||||
if assessment.get("canonical_repo_root") != root:
|
||||
return False
|
||||
if assessment.get("workspace_path") != workspace:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def format_create_issue_bootstrap_error(assessment: dict[str, Any]) -> str:
|
||||
"""RuntimeError / typed-block message for a failed bootstrap assessment."""
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["create_issue bootstrap failed"]
|
||||
)
|
||||
next_action = (
|
||||
assessment.get("exact_next_action") or EXACT_NEXT_ACTION_BOOTSTRAP
|
||||
)
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
return (
|
||||
f"Create-issue bootstrap guard (#749): {reasons}. "
|
||||
f"canonical repository root: {root}; workspace: {workspace}. "
|
||||
f"exact_next_action: {next_action}"
|
||||
)
|
||||
|
||||
|
||||
def _result(
|
||||
*,
|
||||
not_applicable: bool,
|
||||
allowed: bool,
|
||||
block: bool,
|
||||
reasons: list[str],
|
||||
workspace: str,
|
||||
root: str,
|
||||
branch: str | None,
|
||||
dirty: list[str],
|
||||
under_branches: bool,
|
||||
exact_next_action: str | None = None,
|
||||
bootstrap_path: str | None = None,
|
||||
local_head_sha: str | None = None,
|
||||
remote_master_sha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# #757 AC3/AC4: equality is recorded only when BOTH tips are known, so a
|
||||
# consumer can never read agreement out of two missing values.
|
||||
base_tips_verified = bool(
|
||||
local_head_sha and remote_master_sha and local_head_sha == remote_master_sha
|
||||
)
|
||||
return {
|
||||
"not_applicable": not_applicable,
|
||||
"allowed": allowed,
|
||||
"block": block,
|
||||
"proven": allowed and not block,
|
||||
"reasons": list(reasons),
|
||||
"workspace_path": workspace,
|
||||
"canonical_repo_root": root,
|
||||
"current_branch": branch,
|
||||
"dirty_files": list(dirty),
|
||||
"under_branches": under_branches,
|
||||
"exact_next_action": exact_next_action,
|
||||
"bootstrap_path": bootstrap_path,
|
||||
"task_scope": "create_issue_only",
|
||||
"local_head_sha": local_head_sha,
|
||||
"remote_master_sha": remote_master_sha,
|
||||
"base_tips_verified": base_tips_verified,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Durable dependency-edge vocabulary for the control plane (#784, umbrella #628).
|
||||
|
||||
Umbrella #628 scope item 6 requires dependencies to be durable structured state
|
||||
carrying source, target, type, blocking condition, completion condition, current
|
||||
state, and evidence. Before this module the only dependency knowledge in the
|
||||
system was the per-run parse performed by :mod:`allocator_dependencies`, which
|
||||
collapsed into two in-memory ``WorkCandidate`` fields and was then discarded.
|
||||
|
||||
This module owns the vocabulary half of that store:
|
||||
|
||||
* the seven relationship types #628 enumerates;
|
||||
* the three observation states, matching the outcome of
|
||||
:func:`allocator_dependencies.resolve_dependency_state`;
|
||||
* fail-closed normalization for both, plus for work kinds;
|
||||
* the default blocking/completion condition text for each type;
|
||||
* evidence sanitization, so no credential or endpoint ever reaches the store.
|
||||
|
||||
Persistence lives in :mod:`control_plane_db`; ingestion from a live allocation
|
||||
run is :func:`record_issue_dependency_edges`. Nothing here changes allocator
|
||||
selection — this slice records the graph, it does not act on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
# --- Work kinds -------------------------------------------------------------
|
||||
# Mirrors control_plane_db.WORK_KINDS. Declared locally so this module stays
|
||||
# import-light and usable from the DB layer without a circular import.
|
||||
WORK_KIND_ISSUE = "issue"
|
||||
WORK_KIND_PR = "pr"
|
||||
WORK_KINDS = frozenset({WORK_KIND_ISSUE, WORK_KIND_PR})
|
||||
|
||||
# --- Edge types (#628 scope item 6) -----------------------------------------
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE = "issue_blocked_by_issue"
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES = "pr_waiting_for_requested_changes"
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL = "merge_waiting_for_approval"
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE = "reconciliation_waiting_for_merge"
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE = "deployment_waiting_for_infrastructure"
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION = "acceptance_waiting_for_validation"
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX = "task_waiting_for_defect_fix"
|
||||
|
||||
EDGE_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL,
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE,
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Edge states ------------------------------------------------------------
|
||||
# Deliberately three-valued: unavailable evidence is never recorded as met,
|
||||
# matching resolve_dependency_state's fail-closed contract (#758 AC6/AC7).
|
||||
STATE_UNMET = "unmet"
|
||||
STATE_MET = "met"
|
||||
STATE_UNAVAILABLE = "unavailable"
|
||||
|
||||
EDGE_STATES: frozenset[str] = frozenset({STATE_UNMET, STATE_MET, STATE_UNAVAILABLE})
|
||||
|
||||
# Default condition text per edge type: (blocking_condition, completion_condition).
|
||||
DEFAULT_CONDITIONS: dict[str, tuple[str, str]] = {
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE: (
|
||||
"target issue is not closed",
|
||||
"target issue is closed",
|
||||
),
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES: (
|
||||
"requested changes are outstanding at the current head",
|
||||
"requested changes are addressed at the current head",
|
||||
),
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL: (
|
||||
"no approval exists at the current head",
|
||||
"an approval exists at the current head",
|
||||
),
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE: (
|
||||
"target pull request is not merged",
|
||||
"target pull request is merged",
|
||||
),
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE: (
|
||||
"required infrastructure is unavailable",
|
||||
"required infrastructure is available",
|
||||
),
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION: (
|
||||
"required validation evidence is missing",
|
||||
"required validation evidence is recorded",
|
||||
),
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX: (
|
||||
"blocking defect is unresolved or undeployed",
|
||||
"blocking defect is fixed and the runtime carries the fix",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DependencyGraphError(ValueError):
|
||||
"""Base error for dependency-edge vocabulary violations."""
|
||||
|
||||
|
||||
class InvalidEdgeTypeError(DependencyGraphError):
|
||||
"""Raised when an edge type outside :data:`EDGE_TYPES` is supplied."""
|
||||
|
||||
|
||||
class InvalidEdgeStateError(DependencyGraphError):
|
||||
"""Raised when a state outside :data:`EDGE_STATES` is supplied."""
|
||||
|
||||
|
||||
class InvalidEdgeEndpointError(DependencyGraphError):
|
||||
"""Raised when an edge endpoint is not an assignable work unit."""
|
||||
|
||||
|
||||
def normalize_edge_type(value: Any) -> str:
|
||||
"""Return the canonical edge type, or raise fail-closed.
|
||||
|
||||
Unknown values are never coerced to a default: an unrecognized relationship
|
||||
would be stored as an unqueryable free-text row and would silently break
|
||||
reverse lookup for whichever consumer expected the real type.
|
||||
"""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in EDGE_TYPES:
|
||||
raise InvalidEdgeTypeError(
|
||||
f"unknown dependency edge_type '{value}'; expected one of "
|
||||
f"{sorted(EDGE_TYPES)} (fail closed)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_edge_state(value: Any) -> str:
|
||||
"""Return the canonical edge state, or raise fail-closed."""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in EDGE_STATES:
|
||||
raise InvalidEdgeStateError(
|
||||
f"unknown dependency edge state '{value}'; expected one of "
|
||||
f"{sorted(EDGE_STATES)} (fail closed)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_work_kind(value: Any) -> str:
|
||||
"""Return the canonical work kind for an edge endpoint, or raise."""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in WORK_KINDS:
|
||||
raise InvalidEdgeEndpointError(
|
||||
f"dependency edge endpoint kind '{value}' is not assignable work; "
|
||||
f"expected one of {sorted(WORK_KINDS)} (never raw incidents)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def default_conditions(edge_type: str) -> tuple[str, str]:
|
||||
"""Return ``(blocking_condition, completion_condition)`` for *edge_type*."""
|
||||
return DEFAULT_CONDITIONS[normalize_edge_type(edge_type)]
|
||||
|
||||
|
||||
# --- Evidence sanitization --------------------------------------------------
|
||||
|
||||
_SECRET_KEY_PATTERN = re.compile(
|
||||
r"token|secret|password|passwd|authorization|auth_header|credential|api_key"
|
||||
r"|apikey|private_key|cookie|session_token",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
|
||||
|
||||
REDACTED = "[redacted]"
|
||||
|
||||
# Evidence is a small observation record; a deep or huge payload is a sign the
|
||||
# caller is dumping API responses into the store.
|
||||
_MAX_EVIDENCE_DEPTH = 6
|
||||
_MAX_EVIDENCE_STRING = 2000
|
||||
|
||||
|
||||
def sanitize_evidence(payload: Any, *, _depth: int = 0) -> Any:
|
||||
"""Return *payload* with credentials and endpoint URLs removed.
|
||||
|
||||
Applies to every stored evidence record. Keys naming a secret are replaced
|
||||
wholesale; any value containing a URL has the URL replaced, so an endpoint
|
||||
can never be persisted or handed back through a read tool.
|
||||
"""
|
||||
if _depth > _MAX_EVIDENCE_DEPTH:
|
||||
return REDACTED
|
||||
if isinstance(payload, Mapping):
|
||||
clean: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
name = str(key)
|
||||
if _SECRET_KEY_PATTERN.search(name):
|
||||
clean[name] = REDACTED
|
||||
else:
|
||||
clean[name] = sanitize_evidence(value, _depth=_depth + 1)
|
||||
return clean
|
||||
if isinstance(payload, (list, tuple)):
|
||||
return [sanitize_evidence(item, _depth=_depth + 1) for item in payload]
|
||||
if isinstance(payload, str):
|
||||
text = _URL_PATTERN.sub(REDACTED, payload)
|
||||
if len(text) > _MAX_EVIDENCE_STRING:
|
||||
text = text[:_MAX_EVIDENCE_STRING] + "…"
|
||||
return text
|
||||
if isinstance(payload, (int, float, bool)) or payload is None:
|
||||
return payload
|
||||
return sanitize_evidence(str(payload), _depth=_depth + 1)
|
||||
|
||||
|
||||
# --- Ingestion from a live allocation run -----------------------------------
|
||||
|
||||
# Observed live state as recorded in evidence. The exact Gitea state string is
|
||||
# not stored for the unmet case: resolve_dependency_state has already reduced
|
||||
# "any live value other than closed" to unmet, and re-deriving it here would
|
||||
# invent evidence the resolver never produced.
|
||||
OBSERVED_CLOSED = "closed"
|
||||
OBSERVED_NOT_CLOSED = "not_closed"
|
||||
OBSERVED_UNAVAILABLE = "unavailable"
|
||||
|
||||
OBSERVATION_SOURCE_ALLOCATOR = "allocator_live_issue_lookup"
|
||||
|
||||
_OBSERVED_STATE_BY_EDGE_STATE = {
|
||||
STATE_MET: OBSERVED_CLOSED,
|
||||
STATE_UNMET: OBSERVED_NOT_CLOSED,
|
||||
STATE_UNAVAILABLE: OBSERVED_UNAVAILABLE,
|
||||
}
|
||||
|
||||
|
||||
def _observation(state: str, *, observed_by: str | None, subject: str) -> dict[str, Any]:
|
||||
return {
|
||||
"observed_state": _OBSERVED_STATE_BY_EDGE_STATE[state],
|
||||
"observation_source": OBSERVATION_SOURCE_ALLOCATOR,
|
||||
"observed_by_session": observed_by,
|
||||
"declaration": "Depends declaration in issue body",
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
|
||||
def edges_from_dependency_resolution(
|
||||
resolution: Mapping[str, Any],
|
||||
*,
|
||||
source_number: int,
|
||||
observed_by: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert one resolver result into edge records ready for persistence.
|
||||
|
||||
*resolution* is the dict returned by
|
||||
:func:`allocator_dependencies.resolve_dependency_state`. Its ``met`` /
|
||||
``unmet`` / ``unavailable`` partitions map one-to-one onto the stored
|
||||
states, so no dependency is re-classified here.
|
||||
"""
|
||||
subject = f"issue#{int(source_number)}"
|
||||
blocking, completion = default_conditions(EDGE_ISSUE_BLOCKED_BY_ISSUE)
|
||||
records: list[dict[str, Any]] = []
|
||||
partitions: tuple[tuple[str, Iterable[Any]], ...] = (
|
||||
(STATE_MET, resolution.get("met") or ()),
|
||||
(STATE_UNMET, resolution.get("unmet") or ()),
|
||||
(STATE_UNAVAILABLE, resolution.get("unavailable") or ()),
|
||||
)
|
||||
for state, refs in partitions:
|
||||
for ref in refs:
|
||||
records.append(
|
||||
{
|
||||
"source_kind": WORK_KIND_ISSUE,
|
||||
"source_number": int(source_number),
|
||||
"target_kind": WORK_KIND_ISSUE,
|
||||
"target_number": int(ref),
|
||||
"edge_type": EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
"state": state,
|
||||
"blocking_condition": blocking,
|
||||
"completion_condition": completion,
|
||||
"evidence": _observation(
|
||||
state, observed_by=observed_by, subject=subject
|
||||
),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def record_issue_dependency_edges(
|
||||
db: Any,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
source_number: int,
|
||||
resolution: Mapping[str, Any],
|
||||
observed_by: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Persist the edges implied by one candidate's dependency resolution.
|
||||
|
||||
Best-effort by contract: allocation correctness must not depend on this
|
||||
store existing or being writable, so every failure is returned as a reason
|
||||
string and never raised. The caller keeps using the in-memory resolution it
|
||||
already holds.
|
||||
"""
|
||||
try:
|
||||
records = edges_from_dependency_resolution(
|
||||
resolution, source_number=source_number, observed_by=observed_by
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — ingestion never breaks allocation
|
||||
return [f"dependency edge ingestion skipped for issue#{source_number}: {exc}"]
|
||||
|
||||
reasons: list[str] = []
|
||||
for record in records:
|
||||
try:
|
||||
db.upsert_dependency_edge(remote=remote, org=org, repo=repo, **record)
|
||||
except Exception as exc: # noqa: BLE001 — see docstring
|
||||
reasons.append(
|
||||
f"dependency edge not persisted for issue#{source_number} → "
|
||||
f"issue#{record['target_number']}: {exc}"
|
||||
)
|
||||
return reasons
|
||||
@@ -171,13 +171,18 @@ then:
|
||||
- Does not replace CI or code review for MCP changes
|
||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||
|
||||
## 5. Implementation follow-ups (optional tooling)
|
||||
## 5. Implementation follow-ups
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
The **policy binds sessions now**. The enforcement layer landed with issue #615
|
||||
acceptance criteria 6–11 in `stable_control_runtime.py`:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
|
||||
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
|
||||
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
|
||||
|
||||
Post-transport-flap proof is enforced per namespace: a flap invalidates every
|
||||
`gitea-*` namespace at once, and author proof never transfers to reviewer,
|
||||
merger, or reconciler (`namespace_not_reproven_after_flap`).
|
||||
|
||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# ADR: MCP Control Plane Web Console architecture and information architecture
|
||||
|
||||
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
|
||||
- **Date:** 2026-07-22
|
||||
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
|
||||
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
|
||||
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
|
||||
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
|
||||
|
||||
## 1. Context
|
||||
|
||||
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
|
||||
|
||||
Twenty children (#632–#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
|
||||
|
||||
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
|
||||
|
||||
## 2. Decision summary (core)
|
||||
|
||||
| Layer | Owns | Must not |
|
||||
|-------|------|----------|
|
||||
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
|
||||
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
|
||||
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
|
||||
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
|
||||
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
|
||||
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
|
||||
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
|
||||
|
||||
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
|
||||
|
||||
## 3. Console surface today versus target
|
||||
|
||||
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
|
||||
|
||||
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
|
||||
|
||||
## 4. Authority boundaries
|
||||
|
||||
### 4.1 Gitea (durable record)
|
||||
|
||||
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
|
||||
|
||||
### 4.2 Control-plane DB (coordination)
|
||||
|
||||
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
|
||||
|
||||
### 4.3 MCP capability gates (authorization)
|
||||
|
||||
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
|
||||
|
||||
### 4.4 Filesystem and git (local state)
|
||||
|
||||
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
|
||||
|
||||
### 4.5 Providers (observe only)
|
||||
|
||||
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
|
||||
|
||||
## 5. Request flow and the redaction boundary
|
||||
|
||||
```text
|
||||
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
|
||||
│ ├──> control-plane DB
|
||||
│ ├──> filesystem / git
|
||||
│ └──> providers
|
||||
│
|
||||
[redaction boundary]
|
||||
│
|
||||
audit event
|
||||
```
|
||||
|
||||
| Stage | May hold credentials | Emits |
|
||||
|-------|----------------------|-------|
|
||||
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
|
||||
| Route layer → browser | **no** | redacted DTOs, HTML |
|
||||
|
||||
Two invariants govern the boundary and are non-negotiable for every child:
|
||||
|
||||
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
|
||||
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
|
||||
|
||||
## 6. API naming and versioning
|
||||
|
||||
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
|
||||
|
||||
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
|
||||
- Read endpoints are `GET` and side-effect free.
|
||||
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
|
||||
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
|
||||
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
|
||||
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
|
||||
|
||||
## 7. Page map
|
||||
|
||||
| Page | Purpose | Owning child | Evolves |
|
||||
|------|---------|--------------|---------|
|
||||
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
|
||||
| `/system` | System-health dashboard | #639 | new, backed by #634 |
|
||||
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
|
||||
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
|
||||
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
|
||||
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
|
||||
| `/timeline` | Workflow events and conversation timeline | #637 | new |
|
||||
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
|
||||
| `/gitea` | Issue and PR linkage console | #645 | new |
|
||||
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
|
||||
| `/notifications` | Human-attention routing | #648 | new |
|
||||
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
|
||||
| `/providers` | AI-provider connections and insights | #650 | new |
|
||||
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
|
||||
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
|
||||
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
|
||||
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
|
||||
|
||||
## 8. Component ownership for every epic child
|
||||
|
||||
Each #631 child maps to at least one architectural component defined above.
|
||||
|
||||
| Child | Capability area | Primary component | Phase |
|
||||
|-------|-----------------|-------------------|-------|
|
||||
| #632 | Architecture and information architecture | this ADR | 1 |
|
||||
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
|
||||
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
|
||||
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
|
||||
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
|
||||
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
|
||||
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
|
||||
| #639 | System-health dashboard | `/system` page over #634 | 1 |
|
||||
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
|
||||
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
|
||||
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
|
||||
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
|
||||
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
|
||||
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
|
||||
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
|
||||
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
|
||||
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
|
||||
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
|
||||
| #650 | AI-provider connections and operational insights | provider layer | 4 |
|
||||
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
|
||||
|
||||
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
|
||||
|
||||
## 9. Phase gates
|
||||
|
||||
| Phase | May ship | Entry condition |
|
||||
|-------|----------|-----------------|
|
||||
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
|
||||
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
|
||||
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
|
||||
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 1–3 |
|
||||
|
||||
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
|
||||
|
||||
## 10. Security and workflow safety
|
||||
|
||||
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
|
||||
- **Redact by default**, per §5.
|
||||
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
|
||||
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
|
||||
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
|
||||
|
||||
## 11. Forbidden paths
|
||||
|
||||
These are rejected designs, not preferences:
|
||||
|
||||
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
|
||||
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
|
||||
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
|
||||
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
|
||||
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
|
||||
6. **Recreating MVP scope.** Re-implementing a #426–#436 surface without an explicit evolve-or-extend statement is out of bounds.
|
||||
|
||||
## 12. Approval checklist (readable without chat history)
|
||||
|
||||
A controller can accept or reject this ADR against these six points alone:
|
||||
|
||||
1. Layers and their owners are defined (§2) and each authority is named (§4).
|
||||
2. The redaction boundary and the two invariants are stated (§5).
|
||||
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
|
||||
4. A page map exists and names an owning child for every page (§7).
|
||||
5. Every #631 child maps to at least one component and one phase (§8).
|
||||
6. Phase gates and forbidden paths are explicit (§9, §11).
|
||||
|
||||
## 13. Open questions and follow-ups
|
||||
|
||||
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
|
||||
|
||||
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
|
||||
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
|
||||
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
|
||||
- **Retention.** Audit-event retention duration is owned by #633.
|
||||
|
||||
## 14. Acceptance
|
||||
|
||||
Accepting this ADR means:
|
||||
|
||||
- Phase 1 children may proceed against the layers, page map, and API rules above.
|
||||
- Phase 2 children may not open a write path until #633 lands.
|
||||
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Installation root vs canonical target repository root
|
||||
|
||||
## Purpose
|
||||
|
||||
This document (tracked as issue #741, building on #706 and #739/#740) explains
|
||||
the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why
|
||||
conflating them silently targets the wrong repository, and which rule applies
|
||||
when you add a new consumer.
|
||||
|
||||
It is the repository-scope companion to
|
||||
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model)
|
||||
and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md)
|
||||
(the per-role namespace model).
|
||||
|
||||
## The two roots
|
||||
|
||||
| | Installation root | Canonical target repository root |
|
||||
|---|---|---|
|
||||
| What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* |
|
||||
| How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session |
|
||||
| Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable |
|
||||
| Changes at runtime? | No | No — first bind wins for the life of the process |
|
||||
| Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) |
|
||||
|
||||
For a **single-repository** namespace — every Gitea-Tools namespace today — the
|
||||
two roots are the same path, and nothing about the existing behaviour changes.
|
||||
The distinction only becomes observable once a namespace is pointed at a
|
||||
different repository.
|
||||
|
||||
## Which root does my code need?
|
||||
|
||||
Ask what the operation is *about*, not where the file happens to sit.
|
||||
|
||||
**Use the installation root (`PROJECT_ROOT`)** when the operation concerns the
|
||||
Gitea-Tools software itself:
|
||||
|
||||
- server implementation / version parity (`master_parity_gate`, the
|
||||
`startup_head` vs `current_head` staleness gate);
|
||||
- loading the server's own workflow, schema and skill files;
|
||||
- self-code hashing and stale-runtime detection;
|
||||
- locating installed scripts such as `mirror_refs.sh`.
|
||||
|
||||
These are intentionally install-scoped. Do not "fix" them.
|
||||
|
||||
**Use the canonical target root (`_canonical_local_git_root()`)** when the
|
||||
operation concerns the repository being worked on:
|
||||
|
||||
- `git remote get-url` for repository identity;
|
||||
- branch creation, push, and commit;
|
||||
- ancestry and merge-base proofs;
|
||||
- worktree inventory, cleanup, and branch deletion;
|
||||
- any local git subprocess whose result feeds a mutation guard.
|
||||
|
||||
**If you cannot tell, fail closed.** An ambiguous consumer that guesses the
|
||||
install root is the exact defect class #741 exists to eliminate.
|
||||
|
||||
## Why conflating them inverts the guards
|
||||
|
||||
Before #741, `_local_git_remote_url()` ran `git remote get-url` with
|
||||
`cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity* —
|
||||
`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill,
|
||||
`_workspace_repository_slug` — therefore read the Gitea-Tools remote and called
|
||||
it "the workspace", no matter which repository the namespace was bound to.
|
||||
|
||||
For a namespace whose canonical root points elsewhere, this **inverts** the
|
||||
guard rather than merely weakening it:
|
||||
|
||||
- an operation naming the genuinely bound target repository is **rejected**,
|
||||
because that slug does not appear in the Gitea-Tools remote URL;
|
||||
- an operation naming Gitea-Tools is **accepted**.
|
||||
|
||||
The filesystem guards (#274 branches-only and worktree membership) had already
|
||||
been migrated to the canonical root by #706, so the two halves of a single
|
||||
assessment described two different repositories.
|
||||
|
||||
A related subtlety: repository identity must not be derived by looking a remote
|
||||
up by *name*. A target checkout commonly names its remote `origin` rather than
|
||||
`prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall
|
||||
through to the remote-wide default *target* — an unrelated repository.
|
||||
`_canonical_repository_slug()` probes candidate remote names against the
|
||||
canonical root instead.
|
||||
|
||||
`_canonical_local_git_root()` is now the one place a target root is resolved.
|
||||
Do not re-derive it; new code that needs a target root calls that helper.
|
||||
|
||||
## Configuration
|
||||
|
||||
Declare the binding on the profile, alongside `allowed_repositories`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"example-author": {
|
||||
"role": "author",
|
||||
"canonical_repository_root": "/absolute/path/to/target-repo",
|
||||
"allowed_repositories": ["Example-Org/target-repo"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The namespace-scoped environment variable
|
||||
`GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally
|
||||
exported next to the server `cwd` in the MCP client configuration.
|
||||
|
||||
Validation is layered, and each layer fails closed:
|
||||
|
||||
1. **Config load.** The path must be a non-empty absolute string. All supported
|
||||
loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically.
|
||||
(Before #741 only the v2-`contexts` loader validated it, and
|
||||
v2-`environments` silently *dropped* the field during flattening, so the
|
||||
namespace fell back to the install root — a fail-open.)
|
||||
2. **Bind time.** The path must exist, be a git repository, and resolve to a
|
||||
repository identity matching the session's authorized slug. A configured but
|
||||
unresolvable root is never replaced by the install identity.
|
||||
3. **Every mutation.** The pinned root is compared against the live configured
|
||||
value; a mismatch is treated as a forged or conflicting binding.
|
||||
|
||||
`allowed_repositories` remains a separate authorization boundary (#714): the
|
||||
canonical root determines *which* repository is derived, and
|
||||
`allowed_repositories` determines whether the session may act on it. A root that
|
||||
resolves to a repository outside that list fails closed.
|
||||
|
||||
## Explicit coordinates confirm, never override
|
||||
|
||||
Explicit `org`/`repo` arguments may **confirm** an existing canonical binding.
|
||||
They can never establish, complete, or replace one. A request naming a
|
||||
repository that contradicts the binding fails closed, in both directions:
|
||||
|
||||
- a Gitea-Tools-rooted namespace cannot mutate another repository;
|
||||
- a namespace rooted at another repository cannot mutate Gitea-Tools.
|
||||
|
||||
This matters because both-explicit coordinates short-circuit the #530
|
||||
remote/repo match check, so without this rule a caller could name any repository
|
||||
and skip validation entirely.
|
||||
|
||||
No request-supplied workspace, remote, owner, repository, or worktree can
|
||||
replace the immutable root.
|
||||
|
||||
## Parity is reported per dimension
|
||||
|
||||
`gitea_assess_master_parity` reports two separately labelled dimensions:
|
||||
|
||||
- `server_implementation` — the Gitea-Tools installation checkout. Its
|
||||
`startup_head` / `current_head` / `stale` / `restart_required` fields keep
|
||||
their original meaning, and **only this dimension gates mutations**: the
|
||||
running process executes the code it started with, so a merged fix is not live
|
||||
until the daemon restarts.
|
||||
- `target_repository` — the configured canonical target checkout and its
|
||||
last-known remote master.
|
||||
|
||||
"In parity" is a statement about one dimension, never about the whole system.
|
||||
Read the dimension you actually care about.
|
||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
||||
name), never the token itself. Token values are never part of a profile object,
|
||||
never logged, never returned by a tool, and never committed.
|
||||
|
||||
## Repository scope (#714)
|
||||
|
||||
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||
may operate on:
|
||||
|
||||
```json
|
||||
"prgs-author": {
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||
}
|
||||
```
|
||||
|
||||
It is an **authorization boundary, not the session binding**. The binding is
|
||||
derived and enforced like this:
|
||||
|
||||
1. The session repository is derived from the **verified, workspace-aligned git
|
||||
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||
`Timesheet`, which is not this project).
|
||||
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||
organization is derived from the slug; there is no independent,
|
||||
caller-controlled organization value.
|
||||
4. If a profile authorizes several repositories, the verified workspace still
|
||||
selects exactly one. A session never binds to the whole list and never
|
||||
switches between entries.
|
||||
|
||||
Fail-closed rules:
|
||||
|
||||
- Activation is rejected when the workspace repository is absent from the
|
||||
allowlist.
|
||||
- A mutation is rejected when no verified workspace repository can be
|
||||
established.
|
||||
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||
before the mutation runs.
|
||||
- A mutation request can never establish, complete, or replace the binding.
|
||||
|
||||
The field is **config-only**: no environment variable can widen or forge it. It
|
||||
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||
static-profile namespaces stay functional until an operator provisions the
|
||||
scope. Provisioning it is what activates enforcement for that profile.
|
||||
|
||||
## Example profiles
|
||||
|
||||
The following are the reference profiles. Booleans express intended capability
|
||||
@@ -275,6 +317,44 @@ Least-privilege constraints:
|
||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||
`issue.close`, which the production normalizer rejects or drops).
|
||||
|
||||
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
|
||||
|
||||
Neutralising a reviewer lease left behind on an already-merged/closed PR is
|
||||
reconciliation work too. `task_capability_map` maps
|
||||
`cleanup_post_merge_moot_lease` — and its tool-name alias
|
||||
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
|
||||
`gitea.pr.comment` (#745). Both names carry the **same** contract.
|
||||
|
||||
The permission alone is deliberately not sufficient: author, reviewer and
|
||||
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
|
||||
role gate — not the permission gate — is what keeps the terminal lease marker
|
||||
reconciler-owned.
|
||||
|
||||
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
|
||||
|
||||
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
|
||||
This matches `gitea_cleanup_stale_review_decision_lock` and
|
||||
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
|
||||
likewise read-gated, so an operator can diagnose a stuck lease from whichever
|
||||
namespace happens to be attached without switching roles. The dry run
|
||||
performs no mutation and records append-only evidence in-session.
|
||||
- **`apply=true` (mutation) requires all of the following**, in order: the
|
||||
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
|
||||
any other task — including a sibling reconciler task — does not authorize
|
||||
it); the active role must be `reconciler`; the profile must hold
|
||||
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
|
||||
repository identity, which is derived from the session binding and can never
|
||||
be overridden by request parameters; and matching dry-run evidence must show
|
||||
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
|
||||
head and lease marker id that are live at apply time.
|
||||
|
||||
Everything else fails closed: a live lease on an open PR, an already-terminal
|
||||
(idempotent) lease, a lease superseded between the dry run and the apply, a
|
||||
malformed lease missing session/head/marker, and any foreign-repository target.
|
||||
The cleanup only ever appends a terminal `phase: released` marker
|
||||
(`blocker: post-merge-moot`) — it never edits or deletes another session's
|
||||
comment, and it never merges or adopts a lease.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
|
||||
@@ -131,6 +131,44 @@ Suggested lifecycle:
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Terminal PR transitions retire `status:pr-open` (#780)
|
||||
|
||||
`status:pr-open` states that a linked PR is *currently open*. The moment that
|
||||
stops being true the label must go, whatever ended the PR:
|
||||
|
||||
| Terminal reason | Raised by |
|
||||
|---|---|
|
||||
| `merged` | `gitea_merge_pr` |
|
||||
| `closed_without_merge` | `gitea_edit_pr` closing the PR |
|
||||
| `superseded` | `gitea_reconcile_superseded_by_merged_pr` |
|
||||
| `already_landed` | `gitea_reconcile_already_landed_pr` |
|
||||
| `controller_closure` | `gitea_close_issue` |
|
||||
| `abandoned` | abandonment handling |
|
||||
| `retry_recovery` | `gitea_cleanup_terminal_pr_labels` after a partial failure |
|
||||
|
||||
All of these route through one rule in `terminal_pr_label_cleanup.py`, so the
|
||||
paths cannot drift apart. The rule guarantees:
|
||||
|
||||
- only `status:pr-open` is removed — every other label is preserved verbatim;
|
||||
- an empty resulting label set is valid (it was the issue's only label);
|
||||
- an issue that no longer carries the label is a no-op, so retries are safe;
|
||||
- the result is confirmed by a read-after-write re-read, not assumed.
|
||||
|
||||
Controller closure runs the cleanup **before** changing issue state and fails
|
||||
closed if it cannot be completed and verified — closing first would bake in the
|
||||
stale label with no later step to catch it. Post-merge cleanup never blocks the
|
||||
merge: the transition already happened, so failures are reported with a
|
||||
`safe_next_action` instead.
|
||||
|
||||
Use `gitea_assess_terminal_label_hygiene` as terminal validation before
|
||||
declaring a transition or cleanup batch complete. It enumerates issues plus the
|
||||
live open PRs and reports any issue still carrying `status:pr-open` without an
|
||||
open PR to justify it. Issues with a genuinely open PR are exempt, not
|
||||
residual.
|
||||
|
||||
Recovery from a partial failure is `gitea_cleanup_terminal_pr_labels` with
|
||||
`terminal_reason='retry_recovery'`.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
@@ -157,6 +195,10 @@ If a discussion produces implementation work, either:
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
- `gitea_cleanup_terminal_pr_labels` retires `status:pr-open` after a terminal
|
||||
PR transition; it is idempotent, so it is also the retry/recovery path.
|
||||
- `gitea_assess_terminal_label_hygiene` is the read-only terminal validation
|
||||
for residual `status:pr-open`.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
||||
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||
be adapted to other repositories.
|
||||
|
||||
### Sanctioned first mutation: `create_issue` from clean control (#749)
|
||||
|
||||
Creating a tracking issue has no issue number yet, so no `branches/issue-<N>-*`
|
||||
worktree can exist. The sanctioned path is: clean canonical control checkout
|
||||
(accepted base branch, base-equivalent to live master, no tracked dirt) →
|
||||
resolve exact `create_issue` → `gitea_create_issue`. After the issue exists,
|
||||
all further author mutations require a registered issue-backed worktree and
|
||||
lock. Do not improvise with dummy directories, borrowed worktrees, or pre-issue
|
||||
worktrees. See `skills/llm-project-workflow/workflows/create-issue.md` §18a.
|
||||
|
||||
## Principle: the profile is the role, not the LLM
|
||||
|
||||
```text
|
||||
@@ -696,7 +706,9 @@ do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`status:pr-open` (every terminal transition later retires that label
|
||||
automatically — see [`label-taxonomy.md`](label-taxonomy.md)). **Do not**
|
||||
review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
@@ -1231,6 +1243,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
|
||||
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
### Workflow dashboard MCP tool (#605)
|
||||
|
||||
From any healthy Gitea MCP namespace with `gitea.read`:
|
||||
|
||||
```text
|
||||
gitea_workflow_dashboard(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
```
|
||||
|
||||
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
|
||||
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
|
||||
`primary_next_safe_action`. Incomplete inventory fails closed.
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
|
||||
@@ -110,8 +110,49 @@ healthy. See `docs/mcp-namespace-health.md`.
|
||||
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||
reconnect.
|
||||
|
||||
## Sanctioned recovery vs forbidden process manipulation (#630)
|
||||
|
||||
Both restore a working namespace. Only one leaves the session trustworthy.
|
||||
|
||||
**Sanctioned — the runtime is repaired by whoever owns it:**
|
||||
|
||||
- IDE/host auto-reconnect, or an explicit client reconnect (`/mcp reconnect`).
|
||||
- Relaunching the IDE/client so it respawns the daemons it started.
|
||||
- An operator-owned restart performed outside the workflow session.
|
||||
|
||||
**Forbidden — the session manipulates the processes its own proof depends on:**
|
||||
|
||||
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
|
||||
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
|
||||
- Any pattern broad enough to take unrelated namespaces with it
|
||||
(`pkill -f python`), even when it never names MCP.
|
||||
|
||||
Read-only inspection (`ps aux | grep mcp_server`) is neither: it proves nothing
|
||||
and breaks nothing. A `kill` of some unrelated pid is reported as *ambiguous*
|
||||
rather than contaminating, so ordinary subprocess work is never false-blocked.
|
||||
|
||||
**What happens on a detected attempt.** `gitea_record_daemon_process_kill_attempt`
|
||||
classifies a proposed command and, when it is a manual daemon kill, writes a
|
||||
durable contamination marker for the active profile identity. While that marker
|
||||
is live every review / merge / close / completion mutation fails closed;
|
||||
`comment_issue` and `lock_issue` stay allowed so the contaminated worker can
|
||||
still post its audit comment and hand off. The final report must surface the
|
||||
contaminated recovery and must not claim a clean session.
|
||||
|
||||
Contamination is **not self-clearable**. Only
|
||||
`gitea_audit_runtime_recovery_contamination` with `action=clear`, run under a
|
||||
reconciler profile, removes it. The marker is recovery-critical, so it does not
|
||||
expire into cleanliness when the session-state TTL lapses.
|
||||
|
||||
**Operator-authorized host maintenance stays permitted.** Authorization is read
|
||||
from the `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` environment variable
|
||||
and from nowhere else — set outside the session by the operator who owns the
|
||||
host, and recorded as an audit reference on the assessment. It is deliberately
|
||||
not a tool argument: a session must never be able to authorize itself.
|
||||
|
||||
## Related
|
||||
|
||||
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
||||
- #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.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Registered MCP tool inventory
|
||||
|
||||
This is the canonical list of tools the Gitea-Tools MCP server registers. It
|
||||
exists because documentation and the registered inventory drifted: the workflow
|
||||
documented a `gitea_edit_issue` tool that no namespace had ever registered, so a
|
||||
mutation could be planned against a tool that did not exist and only fail at
|
||||
execution time (#781).
|
||||
|
||||
## The rule
|
||||
|
||||
**Documentation must never name a tool an actor cannot reach.**
|
||||
|
||||
Two guards enforce it, both in `tests/test_issue_781_edit_issue_tool.py`:
|
||||
|
||||
1. The list below must equal the registered tool set exactly — sorted, no
|
||||
duplicates, nothing missing in either direction. Adding a tool without
|
||||
documenting it fails, and documenting a tool without registering it fails.
|
||||
2. Every backticked `gitea_*` / `mcp_*` identifier in `skills/**/*.md` must be a
|
||||
registered tool. Module and script names that share the prefix are listed
|
||||
explicitly in `mcp_tool_inventory.NON_TOOL_IDENTIFIERS` rather than being
|
||||
waved through by a looser pattern.
|
||||
|
||||
## Updating this file
|
||||
|
||||
When you add or remove an `@mcp.tool()`, regenerate the block below:
|
||||
|
||||
```bash
|
||||
PYTEST_CURRENT_TEST=1 venv/bin/python -c "
|
||||
import mcp_server, mcp_tool_inventory
|
||||
print(mcp_tool_inventory.render_inventory_block(
|
||||
mcp_server.mcp._tool_manager._tools))
|
||||
"
|
||||
```
|
||||
|
||||
Replace everything between the markers with that output. Do not hand-edit
|
||||
individual entries — the generator and the guard share one ordering rule.
|
||||
|
||||
## Registered tools
|
||||
|
||||
Namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`)
|
||||
register the same tool set; what differs per namespace is the execution profile
|
||||
that gates each call, not which tools exist.
|
||||
|
||||
<!-- BEGIN REGISTERED TOOL INVENTORY -->
|
||||
|
||||
- `gitea_abandon_workflow_lease`
|
||||
- `gitea_acquire_conflict_fix_lease`
|
||||
- `gitea_acquire_merger_pr_lease`
|
||||
- `gitea_acquire_reviewer_pr_lease`
|
||||
- `gitea_activate_profile`
|
||||
- `gitea_adopt_merger_pr_lease`
|
||||
- `gitea_adopt_workflow_lease`
|
||||
- `gitea_allocate_next_work`
|
||||
- `gitea_assess_already_landed_reconciliation`
|
||||
- `gitea_assess_conflict_fix_classification`
|
||||
- `gitea_assess_conflict_fix_push`
|
||||
- `gitea_assess_gitea_operation_path`
|
||||
- `gitea_assess_master_parity`
|
||||
- `gitea_assess_mcp_namespace_health`
|
||||
- `gitea_assess_pr_sync_status`
|
||||
- `gitea_assess_review_merge_state_machine`
|
||||
- `gitea_assess_reviewer_pr_lease`
|
||||
- `gitea_assess_terminal_label_hygiene`
|
||||
- `gitea_assess_work_issue_duplicate`
|
||||
- `gitea_assess_worktree_cleanup_integrity`
|
||||
- `gitea_audit_config`
|
||||
- `gitea_audit_runtime_recovery_contamination`
|
||||
- `gitea_audit_stable_branch_contamination`
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
- `gitea_authorize_review_correction`
|
||||
- `gitea_capability_stop_terminal_report`
|
||||
- `gitea_capture_branches_worktree_snapshot`
|
||||
- `gitea_check_pr_eligibility`
|
||||
- `gitea_cleanup_merged_pr_branch`
|
||||
- `gitea_cleanup_obsolete_reviewer_comment_lease`
|
||||
- `gitea_cleanup_post_merge_moot_lease`
|
||||
- `gitea_cleanup_stale_claims`
|
||||
- `gitea_cleanup_stale_review_decision_lock`
|
||||
- `gitea_cleanup_terminal_pr_labels`
|
||||
- `gitea_close_issue`
|
||||
- `gitea_commit_files`
|
||||
- `gitea_consume_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_create_issue`
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_create_label`
|
||||
- `gitea_create_pr`
|
||||
- `gitea_delete_branch`
|
||||
- `gitea_diagnose_review_decision_lock`
|
||||
- `gitea_diagnose_reviewer_pr_lease_handoff`
|
||||
- `gitea_diagnose_terminal`
|
||||
- `gitea_dry_run_pr_review`
|
||||
- `gitea_edit_issue`
|
||||
- `gitea_edit_pr`
|
||||
- `gitea_expire_workflow_leases`
|
||||
- `gitea_get_authenticated_user`
|
||||
- `gitea_get_current_user`
|
||||
- `gitea_get_file`
|
||||
- `gitea_get_pr_review_feedback`
|
||||
- `gitea_get_profile`
|
||||
- `gitea_get_runtime_context`
|
||||
- `gitea_get_shell_health`
|
||||
- `gitea_heartbeat_reviewer_pr_lease`
|
||||
- `gitea_inspect_workflow_lease`
|
||||
- `gitea_issue_irrecoverable_provenance_authorization`
|
||||
- `gitea_list_dependency_edges`
|
||||
- `gitea_list_issue_comments`
|
||||
- `gitea_list_issues`
|
||||
- `gitea_list_labels`
|
||||
- `gitea_list_profiles`
|
||||
- `gitea_list_prs`
|
||||
- `gitea_list_workflow_leases`
|
||||
- `gitea_load_review_workflow`
|
||||
- `gitea_lock_issue`
|
||||
- `gitea_mark_final_review_decision`
|
||||
- `gitea_mark_issue`
|
||||
- `gitea_merge_pr`
|
||||
- `gitea_mirror_refs`
|
||||
- `gitea_observability_link_issue`
|
||||
- `gitea_observability_list_projects`
|
||||
- `gitea_observability_reconcile_incident`
|
||||
- `gitea_post_heartbeat`
|
||||
- `gitea_quarantine_contaminated_review`
|
||||
- `gitea_reclaim_expired_workflow_lease`
|
||||
- `gitea_reconcile_already_landed_pr`
|
||||
- `gitea_reconcile_issue_claims`
|
||||
- `gitea_reconcile_merged_cleanups`
|
||||
- `gitea_reconcile_superseded_by_merged_pr`
|
||||
- `gitea_record_daemon_process_kill_attempt`
|
||||
- `gitea_record_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_record_pre_review_command`
|
||||
- `gitea_record_shell_spawn_outcome`
|
||||
- `gitea_record_stable_branch_push_attempt`
|
||||
- `gitea_release_merger_pr_lease`
|
||||
- `gitea_release_reviewer_pr_lease`
|
||||
- `gitea_release_workflow_lease`
|
||||
- `gitea_resolve_task_capability`
|
||||
- `gitea_resume_review_draft`
|
||||
- `gitea_review_pr`
|
||||
- `gitea_route_task_session`
|
||||
- `gitea_save_review_draft`
|
||||
- `gitea_scan_already_landed_open_prs`
|
||||
- `gitea_sentry_get_issue_events`
|
||||
- `gitea_sentry_link_gitea_issue`
|
||||
- `gitea_sentry_list_issues`
|
||||
- `gitea_sentry_reconcile_issue`
|
||||
- `gitea_sentry_watchdog`
|
||||
- `gitea_set_issue_labels`
|
||||
- `gitea_submit_pr_review`
|
||||
- `gitea_update_pr_branch_by_merge`
|
||||
- `gitea_validate_review_final_report`
|
||||
- `gitea_view_issue`
|
||||
- `gitea_view_pr`
|
||||
- `gitea_whoami`
|
||||
- `gitea_workflow_dashboard`
|
||||
- `mcp_check_workflow_skill_preflight`
|
||||
- `mcp_get_control_plane_guide`
|
||||
- `mcp_get_skill_guide`
|
||||
- `mcp_list_project_skills`
|
||||
|
||||
<!-- END REGISTERED TOOL INVENTORY -->
|
||||
|
||||
## Issue-content editing
|
||||
|
||||
`gitea_edit_issue` is the only path that changes an issue's title or body. It
|
||||
PATCHes the issue endpoint, refuses a pull-request number, sends only the fields
|
||||
the caller named, and proves the result by read-after-write — including that
|
||||
state, labels, assignees, and milestone did not move.
|
||||
|
||||
`gitea_edit_pr` remains pull-request-only. The two paths never merge: a single
|
||||
tool that accepted either kind would make the narrower capability reachable
|
||||
through the wider one.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Self-hosted Sentry observability for the Gitea MCP server (#606)
|
||||
|
||||
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
|
||||
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
|
||||
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
|
||||
`https://sentry.prgs.cc/`.
|
||||
|
||||
> **Gitea remains the source of truth.** Sentry is observe-only. It never
|
||||
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
|
||||
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
|
||||
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
|
||||
> bridge — never a direct write.
|
||||
|
||||
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the Sentry project
|
||||
|
||||
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
|
||||
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
|
||||
2. Create a new **Python** project named **`gitea-tools-mcp`**.
|
||||
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
|
||||
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
|
||||
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
|
||||
|
||||
## 2. Configure the environment
|
||||
|
||||
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
|
||||
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
|
||||
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
|
||||
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
|
||||
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.0–1.0` (clamped). | `0.0` |
|
||||
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
|
||||
|
||||
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
|
||||
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
|
||||
the SDK is never initialised, and no events are sent — existing tool behaviour
|
||||
and API-call patterns are unchanged.
|
||||
|
||||
### Per-environment examples
|
||||
|
||||
```bash
|
||||
# local (quiet: capture errors/blockers, no traces)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=local
|
||||
|
||||
# dev (light tracing + logs)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=dev
|
||||
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||
export MCP_SENTRY_ENABLE_LOGS=1
|
||||
|
||||
# prod (errors/blockers + low-rate tracing, release-tagged)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=prod
|
||||
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
|
||||
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
|
||||
```
|
||||
|
||||
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
|
||||
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
|
||||
module still imports and every entry point is a safe no-op.
|
||||
|
||||
## 3. What is instrumented
|
||||
|
||||
| Signal | Where | Notes |
|
||||
|--------|-------|-------|
|
||||
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
|
||||
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
|
||||
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
|
||||
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
|
||||
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
|
||||
|
||||
All capture paths are **best-effort / fail open**: a Sentry outage or capture
|
||||
error never breaks an MCP tool success path.
|
||||
|
||||
## 4. Cron / watchdog monitors
|
||||
|
||||
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
|
||||
|
||||
| Registry key | Sentry monitor slug | Wired at |
|
||||
|--------------|--------------------|----------|
|
||||
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
|
||||
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
|
||||
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
|
||||
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
|
||||
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
|
||||
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
|
||||
|
||||
Create matching Cron monitors in Sentry with those slugs. Emit an
|
||||
`in_progress` check-in at job start and `ok`/`error` at completion via
|
||||
`sentry_observability.monitor_checkin(slug_key, status)`.
|
||||
|
||||
## 5. Redaction guarantees (fail closed)
|
||||
|
||||
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
|
||||
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
|
||||
recursively redacts every outgoing event; on any error it drops the event
|
||||
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
|
||||
|
||||
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
|
||||
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
|
||||
ever surfaced as a 12-char `session_id_hash`.
|
||||
- **No** private config contents or raw credential headers.
|
||||
- **No** full local filesystem paths — a worktree path collapses to a coarse
|
||||
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
|
||||
`branches` / `root` / `other`).
|
||||
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
|
||||
|
||||
## 6. Coexistence with GlitchTip / the #612 incident bridge
|
||||
|
||||
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
|
||||
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
|
||||
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
|
||||
and `incident_links` rows.
|
||||
|
||||
- Prefer **one** observability path per environment. Point the MCP server's
|
||||
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
|
||||
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
|
||||
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
|
||||
use, either migrate it to `https://sentry.prgs.cc/` or document the split
|
||||
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
|
||||
- The bridge remains the **only** sanctioned route from an alert back into
|
||||
Gitea workflow state.
|
||||
|
||||
## 7. Reading Sentry back into Gitea (#607)
|
||||
|
||||
[`sentry_incident_bridge.py`](../../sentry_incident_bridge.py) supplies the
|
||||
**read** half of the inbound path: it pulls unresolved issues/events from the
|
||||
self-hosted Sentry API, normalizes them into #612 observations, and hands them
|
||||
to `incident_bridge.reconcile_incident`. It never adds a second linking store —
|
||||
`incident_links` on the #613 control-plane DB stays canonical, which is what
|
||||
makes the mapping survive restarts.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `SENTRY_BASE_URL` | Self-hosted Sentry root | `https://sentry.prgs.cc` |
|
||||
| `SENTRY_AUTH_TOKEN` | API token — **env only**, never logged or returned | _(unset)_ |
|
||||
| `SENTRY_ORG` | Sentry organization slug | _(unset)_ |
|
||||
| `SENTRY_PROJECT` | Sentry project slug | _(unset)_ |
|
||||
| `MCP_SENTRY_ISSUE_BRIDGE_ENABLED` | Required for `apply=true` | `false` |
|
||||
| `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` | Recurrence threshold before an issue is worth filing | `2` |
|
||||
| `MCP_SENTRY_LOOKBACK` | Scan window (`statsPeriod`, e.g. `24h`) | `24h` |
|
||||
|
||||
Missing org/project fails closed as `not_configured`; a missing token fails
|
||||
closed as `missing_token` **before** any HTTP call is made.
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Mode | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `gitea_sentry_list_issues` | read-only | Unresolved issues, `Link`-header pagination |
|
||||
| `gitea_sentry_get_issue_events` | read-only | Sanitized recent + latest event for one issue |
|
||||
| `gitea_sentry_reconcile_issue` | dry-run default | One Sentry issue → durable Gitea issue |
|
||||
| `gitea_sentry_link_gitea_issue` | dry-run default | Link a Sentry issue to an existing Gitea issue |
|
||||
| `gitea_sentry_watchdog` | dry-run default | Scan + create/update issues for active incidents |
|
||||
|
||||
### Policy
|
||||
|
||||
- **Dedupe:** one Sentry issue maps to exactly one Gitea issue, keyed by
|
||||
provider + base URL + org + project + issue id. Recurrence updates the link
|
||||
(and its `event_count`) instead of filing a duplicate.
|
||||
- **No reopen:** a Sentry issue that is no longer `unresolved` is skipped; the
|
||||
bridge never reopens or re-files a closed Gitea issue.
|
||||
- **Threshold:** issues below `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` are skipped, so
|
||||
one-off noise does not become durable work.
|
||||
- **Apply is explicit:** `apply=true` requires both
|
||||
`MCP_SENTRY_ISSUE_BRIDGE_ENABLED` and issue-create permission on the profile.
|
||||
- **Outages fail closed:** an unreachable Sentry returns `sentry_unavailable`
|
||||
and creates nothing.
|
||||
- **Redaction:** secrets are scrubbed and absolute local paths are reduced to a
|
||||
category token (`[path:author]`, `[path:root]`, …) before any value reaches a
|
||||
Gitea issue body. Sensitive tag keys (`authorization`, `cookie`, …) are
|
||||
dropped, and permalinks carrying embedded credentials are discarded entirely.
|
||||
|
||||
## 8. Non-goals
|
||||
|
||||
- Sentry must **not** become the workflow source of truth.
|
||||
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Stable control runtime — promotion runbook (#615)
|
||||
|
||||
Operator / release-manager procedure for promoting a revision into the **stable
|
||||
control runtime**: the Gitea MCP server that performs real issue/PR mutations.
|
||||
|
||||
Policy source: [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||
Enforcement: `stable_control_runtime.py` (runtime mode classification, mutation
|
||||
gates, per-namespace post-flap re-proving, promotion-record validation).
|
||||
|
||||
**Promotion is operator-owned.** Normal author / reviewer / merger / reconciler
|
||||
sessions must never kill, restart, or relaunch the MCP server, and must never
|
||||
edit the stable runtime checkout. A session that needs newer server code stops
|
||||
with `BLOCKED + DIAGNOSE` and hands off to the operator.
|
||||
|
||||
---
|
||||
|
||||
## 1. When a promotion is required
|
||||
|
||||
- A merged PR changes MCP server code the control plane must now enforce.
|
||||
- `gitea_assess_master_parity` reports `stale: true` / `restart_required: true`.
|
||||
- `gitea_get_runtime_context` reports a `runtime_mode` other than
|
||||
`stable-control`, or `real_mutations_allowed: false`.
|
||||
|
||||
## 2. Pre-promotion checks
|
||||
|
||||
Run these **before** advancing the stable checkout:
|
||||
|
||||
1. The target revision is on remote `master` and was merged through
|
||||
`gitea_merge_pr` (never a direct stable-branch push — see #671).
|
||||
2. The stable control checkout is clean (`git status --porcelain` empty) and on
|
||||
`master`. A dirty stable runtime is itself a mutation blocker.
|
||||
3. The advance is strictly fast-forwardable: local `master` is an ancestor of
|
||||
`prgs/master`.
|
||||
4. No active workflow lease is mid-mutation (`gitea_list_workflow_leases`).
|
||||
|
||||
## 3. Promotion steps
|
||||
|
||||
1. Record the **previous** runtime SHA (`gitea_assess_master_parity` →
|
||||
`startup_head`).
|
||||
2. `git fetch --prune prgs` in the stable control checkout.
|
||||
3. `git merge --ff-only prgs/master` — never rebase, reset, or force.
|
||||
4. Record the **promoted** runtime SHA (`git rev-parse HEAD`).
|
||||
5. Reload the runtime using the sanctioned client path (IDE/client reconnect or
|
||||
the operator's supervised service reload). Never `pkill` the daemon from a
|
||||
workflow session.
|
||||
6. Re-prove **each** namespace independently (see §5).
|
||||
7. Record the promotion (see §4) and post it as a durable comment on the
|
||||
tracking issue.
|
||||
|
||||
## 4. Promotion record (required fields)
|
||||
|
||||
Every promotion must record all of the following. `assess_promotion_record()`
|
||||
validates them and fails closed on any missing field, or when
|
||||
`previous_runtime_sha` equals `promoted_runtime_sha` (nothing was promoted).
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `previous_runtime_sha` | SHA the stable runtime was serving before promotion |
|
||||
| `promoted_runtime_sha` | SHA the stable runtime serves after promotion |
|
||||
| `source_branch` | Branch the promoted revision came from |
|
||||
| `source_pr` | PR number that merged it |
|
||||
| `restart_method` | Exact reload/restart mechanism the operator used |
|
||||
| `health_check_proof` | `gitea_assess_mcp_namespace_health` result per namespace |
|
||||
| `identity_proof` | `gitea_whoami` username + profile per namespace |
|
||||
| `profile_proof` | `gitea_get_runtime_context` active profile per namespace |
|
||||
| `workspace_proof` | Process root, canonical root, alignment, clean state |
|
||||
| `mutation_capability_proof` | `gitea_resolve_task_capability` for the intended task |
|
||||
| `rollback_instructions` | Exact steps to return to `previous_runtime_sha` |
|
||||
|
||||
Helper: `scripts/promote-stable-runtime` emits and validates the record. It
|
||||
never restarts anything — it reads state and prints the record for the operator
|
||||
to act on and archive.
|
||||
|
||||
## 5. Post-promotion namespace re-proving
|
||||
|
||||
A restart or transport flap drops every `gitea-*` namespace together. Author
|
||||
proof is **not** global proof. For each of `author`, `reviewer`, `merger`,
|
||||
`reconciler`, in that namespace:
|
||||
|
||||
1. `gitea_whoami`
|
||||
2. `gitea_get_runtime_context`
|
||||
3. `gitea_resolve_task_capability` immediately before the intended mutation
|
||||
4. Mutate only when no reconnect / restart / stale-runtime gate is reported
|
||||
|
||||
Until a namespace passes all four, its mutations stay blocked with
|
||||
`namespace_not_reproven_after_flap`.
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
If the promoted runtime is unhealthy — namespace EOF that does not recover,
|
||||
identity or profile mismatch, capability resolution failure, or an unexpected
|
||||
`runtime_mode`:
|
||||
|
||||
1. **Stop all PR/review/merge work.** An unhealthy stable runtime fails closed;
|
||||
do not route around it.
|
||||
2. Fast-forward or check out `previous_runtime_sha` in the stable checkout.
|
||||
3. Reload the runtime by the same sanctioned method.
|
||||
4. Re-prove every namespace (§5).
|
||||
5. Record the rollback as a promotion record whose `promoted_runtime_sha` is the
|
||||
restored SHA, with the failure evidence in `health_check_proof`.
|
||||
|
||||
## 7. Runtime modes seen in reports
|
||||
|
||||
| Mode | Meaning | Real mutations |
|
||||
|------|---------|----------------|
|
||||
| `stable-control` | Promoted revision, stable branch, clean checkout | Allowed |
|
||||
| `dev-test` | Launched from a `branches/` worktree or a feature branch | Blocked against production |
|
||||
| `unknown` | Root unresolvable, not a git checkout, or detached HEAD with no declaration | Blocked |
|
||||
|
||||
A packaged deployment with no git checkout must declare itself explicitly with
|
||||
`GITEA_MCP_RUNTIME_MODE=stable-control`; an unset or misspelled value falls back
|
||||
to inference and, failing that, to `unknown`.
|
||||
|
||||
## 8. Related
|
||||
|
||||
- `architecture/mcp-stable-control-runtime-policy-adr.md` — the policy (#615)
|
||||
- `mcp-namespace-health.md` — client-namespace health (#543)
|
||||
- `mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery
|
||||
- `mcp-daemon-import-guard.md` — sanctioned daemon only (#558)
|
||||
- `bootstrap-review-path.md` — controller bootstrap when the live runtime cannot
|
||||
review its own fix (#557)
|
||||
@@ -0,0 +1,295 @@
|
||||
# Web console authorization, RBAC, redaction, and audit model (#633)
|
||||
|
||||
**Phase 1. Read-only. This document defines the model that future console
|
||||
writes must pass through; it enables none of them.**
|
||||
|
||||
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
|
||||
documents internal-only serving and states plainly that MVP authentication is
|
||||
*none* — protection comes from network placement. That is adequate while every
|
||||
route is a GET, and inadequate the moment a gated write ships. This document
|
||||
and the three modules it describes land **before** any write exists, so no
|
||||
Phase 2 action can be added without an authority to check it against.
|
||||
|
||||
| Concern | Module |
|
||||
|---------|--------|
|
||||
| Identity, roles, authorization decision | `webui/console_authz.py` |
|
||||
| Secret redaction for every surface | `webui/console_redaction.py` |
|
||||
| Audit event schema, retention, sink | `webui/console_audit.py` |
|
||||
| Machine-readable publication | `GET /api/console/security-model` |
|
||||
|
||||
Two invariants hold everywhere and are non-negotiable for every child of #631:
|
||||
|
||||
1. **No secrets reach the browser.** Credentials are resolved server-side and
|
||||
redacted before any payload, page, log line, or audit record leaves.
|
||||
2. **No ungated mutations.** Authorization is necessary but never sufficient;
|
||||
execution stays disabled until the Phase 2 framework ships.
|
||||
|
||||
## Identity sources
|
||||
|
||||
The console performs *authorization*. Authentication is delegated, because a
|
||||
console that mints its own sessions is a credential store, and this one must
|
||||
not be.
|
||||
|
||||
| Source | Mode value | Authenticated | Shared host | Phase |
|
||||
|--------|-----------|---------------|-------------|-------|
|
||||
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
|
||||
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
|
||||
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
|
||||
|
||||
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
|
||||
rather than erroring open.
|
||||
|
||||
**Access-proxy mode** reads the subject from the
|
||||
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
|
||||
an equivalent org portal that terminates authentication in front of the
|
||||
console. If the header is absent the request did not traverse the proxy, so the
|
||||
principal degrades to anonymous — it is never trusted by default.
|
||||
|
||||
The **role is always server-side configuration**, never a client assertion. It
|
||||
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
|
||||
|
||||
```json
|
||||
{"[email protected]": "operator", "[email protected]": "controller"}
|
||||
```
|
||||
|
||||
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
|
||||
everyone gets `viewer` — a parse failure loses authority rather than granting
|
||||
it.
|
||||
|
||||
Full SSO is explicitly a non-goal of this issue.
|
||||
|
||||
## Role matrix
|
||||
|
||||
Four roles, ordered least to most authority. Each role inherits every lower
|
||||
role's actions; the table states the *minimum* rank required.
|
||||
|
||||
| Role | Authority |
|
||||
|------|-----------|
|
||||
| `viewer` | Read every console view. No write, ever, in any phase. |
|
||||
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
|
||||
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
|
||||
| `admin` | Controller, plus destructive and policy-editing actions. |
|
||||
|
||||
`viewer` holds the empty write set by construction, and a test asserts it stays
|
||||
empty.
|
||||
|
||||
## Privileged actions
|
||||
|
||||
Every console action maps to a `task_key` in `task_capability_map.py`, the same
|
||||
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
|
||||
use. The console therefore cannot invent an authority the MCP layer does not
|
||||
already define, and a regression test asserts each mapping matches.
|
||||
|
||||
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|
||||
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
|
||||
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
|
||||
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
|
||||
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
|
||||
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
|
||||
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
|
||||
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
|
||||
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
|
||||
|
||||
**Dual control** means the acting principal may not be the sole authority: a
|
||||
second distinct principal must confirm. **Break-glass** means the action is
|
||||
expected to be unavailable in normal operation and its use is retained for two
|
||||
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
|
||||
records the requirement on every decision so the framework cannot ship without
|
||||
honouring it.
|
||||
|
||||
`delete_branch` is admin-only rather than controller because it is the one
|
||||
irreversible action in the set.
|
||||
|
||||
### Authorization decision
|
||||
|
||||
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||
record and **denies by default**. The deny reasons are closed and enumerated:
|
||||
|
||||
| Reason code | Meaning |
|
||||
|-------------|---------|
|
||||
| `unknown_action` | No such console action is registered. |
|
||||
| `unauthenticated` | The principal is anonymous. |
|
||||
| `unknown_role` | The role is not in the matrix. |
|
||||
| `insufficient_role` | The role ranks below the action's minimum. |
|
||||
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||
|
||||
There is no implicit allow branch. Even the allow result reports
|
||||
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
||||
read an allow as permission to mutate.
|
||||
|
||||
## Secret redaction
|
||||
|
||||
One pass applies to **API payloads, rendered HTML, server logs, and audit
|
||||
records** — the four surfaces where a credential could escape.
|
||||
|
||||
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
|
||||
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
|
||||
The console layer then applies its own patterns:
|
||||
|
||||
Each rule below matches an *assignment form*: the named key, followed by `=` or
|
||||
`:`, followed by the value. The keys are listed bare rather than spelled out as
|
||||
complete assignments, because this document is itself scanned by
|
||||
`scan_for_secrets` — writing the examples in full assignment form would make the
|
||||
documentation trip the very detectors it documents.
|
||||
|
||||
| Rule | Catches (as an assignment) |
|
||||
|------|----------------------------|
|
||||
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
|
||||
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
|
||||
| `keychain_reference` | `keychain:` entry references |
|
||||
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
|
||||
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
|
||||
| `json_web_token` | Three-segment `eyJ...` JWTs |
|
||||
| `bearer_credential` | `Bearer` / `Basic` credentials |
|
||||
|
||||
Assignments keep the key and replace only the value, so an operator can still
|
||||
see *what* was removed. Two behaviours are deliberate:
|
||||
|
||||
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
|
||||
outright rather than being emitted raw. Redaction never raises.
|
||||
- **Redact before persist.** `console_audit.build_event` redacts before
|
||||
serialization, and `write_event` re-scans and **drops** any record that still
|
||||
trips a detector. An unredacted record is never durable.
|
||||
|
||||
`scan_for_secrets` is the assertion helper: it returns the detector names still
|
||||
matching a payload, and already-redacted hits are not findings. Tests use it to
|
||||
prove the published policy, the security-model endpoint, and this document
|
||||
itself carry no secret material.
|
||||
|
||||
## Audit event schema
|
||||
|
||||
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
|
||||
performed which tool call. It has no console actor, no identity source, no
|
||||
correlation identifier, and no retention class, and an authorization **denial**
|
||||
is not a mutation, so it would never appear there at all. The console record is
|
||||
additive, not a replacement: a Phase 2 action emits both, joined on
|
||||
`correlation.request_id`.
|
||||
|
||||
Required fields, all asserted by tests so an edit cannot quietly drop one:
|
||||
|
||||
| Field | Content |
|
||||
|-------|---------|
|
||||
| `schema_version` | Currently `1`. |
|
||||
| `event_id` | Unique per record. |
|
||||
| `timestamp` | Timezone-aware ISO-8601, UTC. |
|
||||
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
|
||||
| `action` | Console action id. |
|
||||
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
|
||||
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
|
||||
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
|
||||
| `reason_code` | The authorization reason code above. |
|
||||
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
|
||||
| `retention` | `class`, `days`, `expires_at`. |
|
||||
| `redacted` | Always `true`; records are redacted at build time. |
|
||||
|
||||
An unrecognised `result` degrades to `failed` rather than being stored
|
||||
verbatim.
|
||||
|
||||
The sink is an append-only JSON Lines file named by
|
||||
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
|
||||
events are still built — so callers and tests exercise the schema — but nothing
|
||||
is written. Auditing never raises; a failed write returns `False` rather than
|
||||
breaking the request it describes.
|
||||
|
||||
## Retention
|
||||
|
||||
| Class | Applies to | Default |
|
||||
|-------|-----------|---------|
|
||||
| `standard` | Routine gated writes | 90 days |
|
||||
| `privileged` | `review_pr`, `close_pr`, and any unclassifiable action | 365 days |
|
||||
| `break_glass` | `merge_pr`, `delete_branch` | 730 days |
|
||||
|
||||
Each record carries its own class, day count, and computed `expires_at`, so
|
||||
retention is auditable per record rather than inferred from file age. An
|
||||
**unknown action is retained as privileged, not standard** — for a safety
|
||||
control the conservative direction is to keep the record longer.
|
||||
|
||||
Nothing in this module updates or deletes. Expiry is enforced by an
|
||||
operator-run policy against `expires_at`, never by the console silently
|
||||
rewriting its own history.
|
||||
|
||||
## Phase 2 integration
|
||||
|
||||
Phase 2 opens gated writes. It must reuse this model rather than introduce a
|
||||
second one. The integration points are already wired and observable:
|
||||
|
||||
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
|
||||
to the existing preview payload and records a `previewed` audit event.
|
||||
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
|
||||
records a `denied` event. The terminal outcome is unchanged — the MVP
|
||||
registry in `webui/gated_actions.py` still fails closed for every action — so
|
||||
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
|
||||
instead of adding a parallel check.
|
||||
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||
policy, and audit policy as JSON for operators and tests.
|
||||
|
||||
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
||||
confirmation and dual-control flow the matrix already declares, emit a
|
||||
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
||||
keep `viewer` unable to reach any of it. Turning on execution without the
|
||||
confirmation flow contradicts a declared requirement and is a review failure,
|
||||
not a shortcut.
|
||||
|
||||
## Local-dev mode
|
||||
|
||||
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
|
||||
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
|
||||
|
||||
**INSECURE — this mode is for loopback development only.** The subject and role
|
||||
are *asserted by the developer running the process and verified by nothing*.
|
||||
Anyone able to set an environment variable on the host is an `admin`, and
|
||||
anyone able to reach the port inherits that principal. It provides no
|
||||
authentication whatsoever; it exists so Phase 2 authorization paths can be
|
||||
exercised without standing up a proxy.
|
||||
|
||||
Never enable local-dev mode on a non-loopback bind. Combining it with
|
||||
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
|
||||
unauthenticated admin console.
|
||||
|
||||
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
|
||||
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
|
||||
|
||||
### Probe authentication
|
||||
|
||||
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
|
||||
authenticated principal. It is **opt-in**: the default is off so the MVP
|
||||
`/health` contract is unchanged.
|
||||
|
||||
**This flag is declarative in Phase 1 and enforces nothing today.**
|
||||
`console_authz.probe_auth_required()` reports the operator's intent, and no
|
||||
route consults it — setting the variable does not currently change the
|
||||
behaviour of `/health` or any other endpoint. It is published here so the Phase
|
||||
2 action framework has a declared policy to honour rather than inventing a
|
||||
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
|
||||
already declared. A regression test pins this "declared, not enforced" status,
|
||||
so wiring it later is a deliberate change rather than a silent one.
|
||||
|
||||
Until Phase 2 wires it, probe protection rests on network placement alone, as
|
||||
[`webui-deployment.md`](webui-deployment.md) (#435) states.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
|
||||
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
|
||||
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
|
||||
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||
|
||||
All are read server-side only. None is ever rendered into a page or returned by
|
||||
an API.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No full SSO product; authentication stays delegated to the proxy.
|
||||
- No browser-initiated merges or approvals in any phase covered here.
|
||||
- No tokens in the frontend, in browser storage, or in committed config.
|
||||
@@ -7,7 +7,10 @@ only.
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Authentication:** none in MVP — protection comes from network placement.
|
||||
The authorization, RBAC, redaction, and audit model that future gated writes
|
||||
must pass through is defined in
|
||||
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
|
||||
@@ -37,6 +37,12 @@ Optional environment variables:
|
||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
|
||||
See
|
||||
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
for the console architecture: layer and authority boundaries, the redaction
|
||||
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
|
||||
that govern when a write path may open (#632, epic #631).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
"""Authoritative rule for editing an issue's title and body (#781).
|
||||
|
||||
The workflow documented a ``gitea_edit_issue`` tool that was never registered,
|
||||
so an authorized body correction on an issue had no sanctioned path at all: the
|
||||
only edit tool, ``gitea_edit_pr``, PATCHes the pull-request endpoint and cannot
|
||||
target an issue. This module is the rule that path is built on, kept separate
|
||||
from the pull-request edit path by construction.
|
||||
|
||||
- :func:`validate_edit_request` rejects structurally invalid requests before any
|
||||
credential, network, or profile work happens. A request that names no field,
|
||||
or names one with the wrong type, is a pure input error.
|
||||
- :func:`assess_issue_target` refuses a pull request. Gitea serves pull requests
|
||||
from the same ``/issues/{n}`` collection, so without this check the issue edit
|
||||
path would quietly become a second, ungated PR edit path.
|
||||
- :func:`plan_issue_edit` decides the exact PATCH payload from the pre-image. It
|
||||
only ever sends fields the caller named, and it reports a request that would
|
||||
change nothing as an explicit no-op rather than a silent success.
|
||||
- :func:`verify_issue_edit` is the read-after-write check. It proves the applied
|
||||
title/body match what was requested *and* that every field the caller did not
|
||||
name — state, labels, assignees, milestone — is unchanged.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: Fields this tool is allowed to change. Anything else must be untouched.
|
||||
EDITABLE_FIELDS: tuple[str, ...] = ("title", "body")
|
||||
|
||||
#: Fields the caller never names and which must survive an edit verbatim.
|
||||
PRESERVED_FIELDS: tuple[str, ...] = ("state", "labels", "assignees", "milestone")
|
||||
|
||||
|
||||
def validate_edit_request(
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return the requested field map, failing closed on an invalid request.
|
||||
|
||||
Raises ``ValueError`` when no field is named, when a named field is not a
|
||||
string, or when a title is blank. An empty *body* is legitimate — clearing
|
||||
an issue description is a real edit — but an empty title is not, because
|
||||
Gitea has no issue without one.
|
||||
"""
|
||||
requested: dict[str, str] = {}
|
||||
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError(
|
||||
f"Invalid title type {type(title).__name__}: title must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
if not title.strip():
|
||||
raise ValueError(
|
||||
"Invalid title: an issue title cannot be blank. Pass the exact "
|
||||
"replacement title, or omit title= to leave it unchanged "
|
||||
"(fail closed)."
|
||||
)
|
||||
requested["title"] = title
|
||||
|
||||
if body is not None:
|
||||
if not isinstance(body, str):
|
||||
raise ValueError(
|
||||
f"Invalid body type {type(body).__name__}: body must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
requested["body"] = body
|
||||
|
||||
if not requested:
|
||||
raise ValueError(
|
||||
"At least one field to edit (title, body) must be provided. "
|
||||
"gitea_edit_issue never edits state, labels, assignees, or "
|
||||
"milestone (fail closed)."
|
||||
)
|
||||
|
||||
return requested
|
||||
|
||||
|
||||
def assess_issue_target(
|
||||
issue: Mapping[str, Any],
|
||||
*,
|
||||
issue_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm the fetched object is an issue and not a pull request.
|
||||
|
||||
Gitea serves pull requests from ``/issues/{n}`` as well, so a PR number
|
||||
reaches this path unchallenged. Issue and pull-request edits stay separate
|
||||
capabilities, so a PR target is refused here rather than silently PATCHed.
|
||||
"""
|
||||
is_pull_request = bool(issue.get("pull_request"))
|
||||
return {
|
||||
"is_issue": not is_pull_request,
|
||||
"is_pull_request": is_pull_request,
|
||||
"reasons": (
|
||||
[
|
||||
f"#{issue_number} is a pull request, not an issue; "
|
||||
"gitea_edit_issue never edits pull requests"
|
||||
]
|
||||
if is_pull_request
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
f"Use gitea_edit_pr for pull request #{issue_number}."
|
||||
if is_pull_request
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def preserved_snapshot(issue: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Capture the fields an edit must leave alone, in a comparable shape."""
|
||||
return {
|
||||
"state": issue.get("state"),
|
||||
"labels": issue_workflow_labels.label_names(issue),
|
||||
"assignees": _assignee_names(issue),
|
||||
"milestone": _milestone_key(issue),
|
||||
}
|
||||
|
||||
|
||||
def _assignee_names(issue: Mapping[str, Any]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for entry in issue.get("assignees") or []:
|
||||
if isinstance(entry, Mapping):
|
||||
login = entry.get("login") or entry.get("username")
|
||||
else:
|
||||
login = entry
|
||||
if login:
|
||||
names.append(str(login))
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_key(issue: Mapping[str, Any]) -> str | None:
|
||||
milestone = issue.get("milestone")
|
||||
if not milestone:
|
||||
return None
|
||||
if isinstance(milestone, Mapping):
|
||||
key = milestone.get("title") or milestone.get("id")
|
||||
return None if key is None else str(key)
|
||||
return str(milestone)
|
||||
|
||||
|
||||
def plan_issue_edit(
|
||||
current: Mapping[str, Any],
|
||||
*,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the PATCH payload for an issue edit against its pre-image.
|
||||
|
||||
Only fields the caller named are ever put in the payload, so unspecified
|
||||
fields cannot be overwritten with a stale read. A request whose named fields
|
||||
already hold the requested values is reported as a no-op with an actionable
|
||||
reason instead of being sent and reported as a success.
|
||||
"""
|
||||
requested = validate_edit_request(title=title, body=body)
|
||||
number = issue_number if issue_number is not None else current.get("number")
|
||||
|
||||
changes: dict[str, dict[str, Any]] = {}
|
||||
unchanged: list[str] = []
|
||||
for field, value in requested.items():
|
||||
before = current.get(field)
|
||||
if field == "body":
|
||||
before = before or ""
|
||||
if before == value:
|
||||
unchanged.append(field)
|
||||
else:
|
||||
changes[field] = {"before": before, "after": value}
|
||||
|
||||
no_op = not changes
|
||||
payload = {field: requested[field] for field in changes}
|
||||
|
||||
return {
|
||||
"issue_number": number,
|
||||
"requested_fields": sorted(requested),
|
||||
"requested": dict(requested),
|
||||
"payload": payload,
|
||||
"changes": changes,
|
||||
"unchanged_fields": sorted(unchanged),
|
||||
"no_op": no_op,
|
||||
"preserved_before": preserved_snapshot(current),
|
||||
"reasons": (
|
||||
[
|
||||
"requested "
|
||||
+ ", ".join(sorted(unchanged))
|
||||
+ " already match the issue's current content; no edit was sent"
|
||||
]
|
||||
if no_op
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
(
|
||||
f"Re-read issue #{number} and call gitea_edit_issue only with "
|
||||
"content that differs, or drop the call if the issue is already "
|
||||
"correct."
|
||||
)
|
||||
if no_op
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def verify_issue_edit(
|
||||
observed: Mapping[str, Any],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write proof for an applied issue edit.
|
||||
|
||||
Fails closed on two distinct defects: an edited field whose stored value is
|
||||
not what was requested, and an untouched field that moved anyway.
|
||||
"""
|
||||
requested = dict(plan.get("requested") or {})
|
||||
number = plan.get("issue_number")
|
||||
|
||||
applied: dict[str, Any] = {}
|
||||
mismatches: list[dict[str, Any]] = []
|
||||
for field, expected in requested.items():
|
||||
actual = observed.get(field)
|
||||
if field == "body":
|
||||
actual = actual or ""
|
||||
applied[field] = actual
|
||||
if actual != expected:
|
||||
mismatches.append(
|
||||
{"field": field, "expected": expected, "observed": actual}
|
||||
)
|
||||
|
||||
before = dict(plan.get("preserved_before") or {})
|
||||
after = preserved_snapshot(observed)
|
||||
preserved_changed: list[dict[str, Any]] = [
|
||||
{"field": field, "before": before.get(field), "after": after.get(field)}
|
||||
for field in PRESERVED_FIELDS
|
||||
if before.get(field) != after.get(field)
|
||||
]
|
||||
|
||||
reasons: list[str] = []
|
||||
for entry in mismatches:
|
||||
reasons.append(
|
||||
f"{entry['field']} was not applied: requested "
|
||||
f"{entry['expected']!r} but the issue stores {entry['observed']!r}"
|
||||
)
|
||||
for entry in preserved_changed:
|
||||
reasons.append(
|
||||
f"{entry['field']} changed during the edit: {entry['before']!r} "
|
||||
f"became {entry['after']!r}; gitea_edit_issue must leave it alone"
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"applied": applied,
|
||||
"mismatches": mismatches,
|
||||
"preserved_before": before,
|
||||
"preserved_after": after,
|
||||
"preserved_changed": preserved_changed,
|
||||
"preserved_intact": not preserved_changed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
f"Re-read issue #{number} with gitea_view_issue and reconcile it "
|
||||
"before treating the edit as applied. Do not retry blindly — the "
|
||||
"stored content does not match what was requested."
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -16,9 +16,14 @@ import issue_acceptance_gate
|
||||
import issue_lock_provenance
|
||||
import merger_lease_adoption
|
||||
import reviewer_handoff_consistency
|
||||
import runtime_recovery_guard
|
||||
import thread_state_ledger_validator
|
||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from self_propagating_handoff import (
|
||||
HANDOFF_HEADING as SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
assess_final_report_self_propagating_handoff,
|
||||
)
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -728,6 +733,65 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
_MUTATION_ACCOUNTING_PATTERNS = {
|
||||
"local_failed_attempts": re.compile(
|
||||
r"local\s+failed\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"blocked_api_attempts": re.compile(
|
||||
r"blocked\s+api\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"successful_server_mutations": re.compile(
|
||||
r"successful\s+server(?:[-\s]side)?\s+mutations\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
_READBACK_VERIFIED_PATTERN = re.compile(
|
||||
r"read[-\s]?after[-\s]?write\s+verified\s*:\s*(yes|true)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_mutation_budget_accounting(
|
||||
report_text: str,
|
||||
*,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#617: mutation budget counts server-side changes only.
|
||||
|
||||
No-op unless the session supplies an attempt ledger. When it does, the
|
||||
report's three attempt categories must match the ledger exactly, so a
|
||||
pre-API validator rejection can never be reported as a Gitea mutation and
|
||||
a real mutation can never be hidden.
|
||||
"""
|
||||
if mutation_attempt_ledger is None:
|
||||
return []
|
||||
|
||||
from mutation_budget_classifier import assess_final_report_mutation_accounting
|
||||
|
||||
text = report_text or ""
|
||||
claimed: dict[str, Any] = {}
|
||||
for field, pattern in _MUTATION_ACCOUNTING_PATTERNS.items():
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
claimed[field] = int(match.group(1))
|
||||
if _READBACK_VERIFIED_PATTERN.search(text):
|
||||
claimed["readback_verified"] = True
|
||||
|
||||
result = assess_final_report_mutation_accounting(claimed, mutation_attempt_ledger)
|
||||
if result.get("valid"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.mutation_budget_accounting",
|
||||
result.get("reasons") or [],
|
||||
field="Mutation accounting",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"report 'Local failed attempts:', 'Blocked API attempts:', and "
|
||||
"'Successful server-side mutations:' with counts matching the "
|
||||
"attempt ledger; pre-API rejections are not Gitea mutations"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report,
|
||||
@@ -1564,6 +1628,21 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_self_propagating_handoff(report_text: str) -> list[dict[str, str]]:
|
||||
"""#626: a report that adopts the handoff protocol must complete it."""
|
||||
result = assess_final_report_self_propagating_handoff(report_text)
|
||||
if not result.get("applicable") or not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.self_propagating_handoff",
|
||||
result.get("reasons") or ["incomplete canonical handoff"],
|
||||
field=SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "complete every canonical handoff field before posting",
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
@@ -1584,13 +1663,24 @@ _SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_SHARED_MUTATION_BUDGET_RULES = (
|
||||
_rule_shared_mutation_budget_accounting,
|
||||
)
|
||||
|
||||
# #626: enforced for every task kind that can continue the workflow chain.
|
||||
_SHARED_SELF_PROPAGATING_HANDOFF_RULES = (
|
||||
_rule_shared_self_propagating_handoff,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1620,6 +1710,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"merge_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
@@ -1631,11 +1722,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1650,20 +1743,24 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_audit_reconciliation_boundary,
|
||||
],
|
||||
"author_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1672,28 +1769,34 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_worktree_cleanup_audit_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
# Controller issue closure (#529): a closure report must not bury an
|
||||
@@ -1701,6 +1804,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||
# full reviewer/author handoff schema.
|
||||
"controller_close": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reviewer_premerge_baseline_proof,
|
||||
],
|
||||
}
|
||||
@@ -1766,6 +1870,8 @@ def assess_final_report_validator(
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
runtime_recovery_marker: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1804,6 +1910,28 @@ def assess_final_report_validator(
|
||||
action_log = sanitized_action_log
|
||||
findings.extend(action_log_findings)
|
||||
|
||||
# #630 scope item 4: while a manual daemon-kill contamination marker is
|
||||
# live, the report must surface it and must not claim a clean session.
|
||||
if runtime_recovery_marker:
|
||||
runtime_recovery = runtime_recovery_guard.assess_final_report_claim(
|
||||
report_text,
|
||||
runtime_recovery_marker,
|
||||
)
|
||||
checks["runtime_recovery_contamination"] = runtime_recovery
|
||||
if runtime_recovery.get("block"):
|
||||
findings.extend(
|
||||
_findings_from_reasons(
|
||||
"shared.runtime_recovery_contamination",
|
||||
runtime_recovery.get("reasons") or [],
|
||||
field="Runtime recovery",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state the manual daemon kill and the pending reconciler "
|
||||
"audit in the report; remove any clean-session claim"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||
report_text,
|
||||
@@ -1829,6 +1957,7 @@ def assess_final_report_validator(
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
"mutation_attempt_ledger": mutation_attempt_ledger,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"execution_profile": "example-author",
|
||||
"audit_label": "example-author",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||
},
|
||||
|
||||
+51
-3
@@ -182,11 +182,51 @@ def get_auth_header(host):
|
||||
|
||||
def resolve_remote(args):
|
||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||
return (host, org, repo) with overrides applied."""
|
||||
return (host, org, repo) with overrides applied.
|
||||
|
||||
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||
``--remote prgs`` must not force Timesheet when the checkout is
|
||||
Gitea-Tools). Explicit --org/--repo always win.
|
||||
"""
|
||||
profile = REMOTES[args.remote]
|
||||
host = args.host or profile["host"]
|
||||
org = args.org or profile["org"]
|
||||
repo = args.repo or profile["repo"]
|
||||
org_explicit = getattr(args, "org", None) is not None
|
||||
repo_explicit = getattr(args, "repo", None) is not None
|
||||
org = args.org if org_explicit else profile["org"]
|
||||
repo = args.repo if repo_explicit else profile["repo"]
|
||||
if not org_explicit or not repo_explicit:
|
||||
try:
|
||||
import remote_repo_guard
|
||||
import subprocess
|
||||
# Prefer the named remote URL when present; fall back to origin.
|
||||
url = None
|
||||
for remote_name in (args.remote, "origin"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||
url = proc.stdout.strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Allow tests to inject a deterministic remote URL without Git.
|
||||
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||
if env_url:
|
||||
url = env_url
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
if not org_explicit:
|
||||
org = parsed[0]
|
||||
if not repo_explicit:
|
||||
repo = parsed[1]
|
||||
except Exception:
|
||||
pass
|
||||
return host, org, repo
|
||||
|
||||
|
||||
@@ -758,6 +798,14 @@ def get_profile():
|
||||
"profile_name": name,
|
||||
"allowed_operations": ops,
|
||||
"forbidden_operations": forbidden,
|
||||
# #714 repository authorization boundary. Config-only on purpose: an
|
||||
# environment variable must never widen or forge the set of
|
||||
# repositories a session may bind to.
|
||||
"allowed_repositories": _json_list("allowed_repositories"),
|
||||
# #706 cross-repository canonical root binding. Config-sourced here (the
|
||||
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
|
||||
# by canonical_repository_root.configured_canonical_root, not widened here).
|
||||
"canonical_repository_root": jp.get("canonical_repository_root") or None,
|
||||
"audit_label": audit_label,
|
||||
"token_source_name": token_source,
|
||||
"auth_source_type": auth_type,
|
||||
|
||||
@@ -272,6 +272,15 @@ def load_config(path=None):
|
||||
)
|
||||
if not isinstance(data.get("profiles"), dict):
|
||||
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
||||
# #741: the v1 path returns `data` unflattened, so nothing else validates
|
||||
# the cross-repository binding before gitea_auth.get_profile() reads it.
|
||||
# Validate it here so every supported loader treats the field identically
|
||||
# (a relative or blank path must never reach the runtime guard).
|
||||
for _name, _profile in data["profiles"].items():
|
||||
if isinstance(_profile, dict):
|
||||
_validate_canonical_repository_root(
|
||||
_name, _profile.get("canonical_repository_root")
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
|
||||
for key in ("role", "username", "execution_profile", "audit_label"):
|
||||
if ident.get(key):
|
||||
profile[key] = ident[key]
|
||||
# #741: the cross-repository binding must survive flattening. Previously
|
||||
# this key was silently dropped here, so a v2-environments namespace that
|
||||
# declared canonical_repository_root fell back to the *installation* root
|
||||
# and mutated Gitea-Tools instead of its target repository — a fail-open.
|
||||
# Validate it exactly as the v2-contexts loader does before propagating.
|
||||
_validate_canonical_repository_root(addr, ident.get("canonical_repository_root"))
|
||||
if ident.get("canonical_repository_root"):
|
||||
profile["canonical_repository_root"] = ident["canonical_repository_root"]
|
||||
return addr, profile
|
||||
|
||||
|
||||
@@ -475,6 +492,57 @@ def _require_enabled(kind, name, obj):
|
||||
return enabled
|
||||
|
||||
|
||||
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||
|
||||
|
||||
def _validate_allowed_repositories(name, raw):
|
||||
"""Validate the optional per-profile repository authorization scope (#714).
|
||||
|
||||
``allowed_repositories`` is an authorization boundary of canonical
|
||||
``owner/repository`` slugs. It is not the session binding: the verified
|
||||
workspace repository selects exactly one entry at bind time. Absent means
|
||||
"no repository scope configured" and is allowed, so existing profiles keep
|
||||
working until an operator provisions the field.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories must be a list of "
|
||||
"'owner/repository' strings"
|
||||
)
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||
"a canonical 'owner/repository' slug"
|
||||
)
|
||||
|
||||
|
||||
def _validate_canonical_repository_root(name, raw):
|
||||
"""Validate the optional per-profile canonical repository root (#706).
|
||||
|
||||
``canonical_repository_root`` binds a cross-repository namespace to the
|
||||
working root of its target repository (separate from the immutable
|
||||
Gitea-Tools install checkout). It is an absolute filesystem path; existence
|
||||
and git identity are validated at bind time by the runtime guard, not here
|
||||
(config validation stays filesystem-independent). Absent means the
|
||||
single-repo default and is allowed.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root must be a non-empty "
|
||||
"absolute path string to the target repository working root"
|
||||
)
|
||||
if not os.path.isabs(raw.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root {raw!r} must be an "
|
||||
"absolute path"
|
||||
)
|
||||
|
||||
|
||||
def _reject_inline_secrets(kind, name, obj):
|
||||
for key in _INLINE_SECRET_KEYS:
|
||||
if key in obj:
|
||||
@@ -549,6 +617,10 @@ def _load_v2_contexts(data, path):
|
||||
forbidden = raw.get("forbidden_operations") or []
|
||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||
_validate_canonical_repository_root(
|
||||
name, raw.get("canonical_repository_root")
|
||||
)
|
||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||
|
||||
+6602
-438
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,78 @@ def build_gitea_issue_body(inc: NormalizedIncident) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def incident_recurred(
|
||||
existing: dict[str, Any], inc: NormalizedIncident
|
||||
) -> tuple[bool, str]:
|
||||
"""Did new provider events arrive since the existing link was last synced?
|
||||
|
||||
AC4 asks for a recurrence comment when events *continue*, so a scan that
|
||||
observes no new events must stay silent instead of re-posting the same
|
||||
state on every pass.
|
||||
"""
|
||||
old_count = existing.get("event_count")
|
||||
new_count = inc.event_count
|
||||
if (
|
||||
isinstance(old_count, int)
|
||||
and isinstance(new_count, int)
|
||||
and new_count > old_count
|
||||
):
|
||||
return True, f"event_count advanced {old_count} -> {new_count}"
|
||||
old_seen = str(existing.get("last_seen") or "").strip()
|
||||
new_seen = str(inc.last_seen or "").strip()
|
||||
if new_seen and new_seen != old_seen:
|
||||
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
|
||||
return False, "no new provider events since the last sync"
|
||||
|
||||
|
||||
def build_recurrence_comment_body(
|
||||
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
|
||||
) -> str:
|
||||
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
|
||||
|
||||
Uses the same redaction path as :func:`build_gitea_issue_body`; never
|
||||
carries tokens, raw paths, or session state.
|
||||
"""
|
||||
lines = [
|
||||
"## Observability incident recurrence (bridge #612)",
|
||||
"",
|
||||
"<!-- mcp-incident-bridge:recurrence:v1 -->",
|
||||
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||
"",
|
||||
f"Continued `{inc.provider}` events for this linked incident.",
|
||||
"",
|
||||
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"- **event_count:** `{existing.get('event_count')}` -> "
|
||||
f"`{inc.event_count if inc.event_count is not None else ''}`",
|
||||
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||
f"- **environment:** `{inc.environment or ''}`",
|
||||
f"- **severity:** `{inc.severity or ''}`",
|
||||
f"- **culprit:** `{inc.culprit or ''}`",
|
||||
f"- **status:** `{inc.status}`",
|
||||
f"- **recurrence_basis:** `{reason}`",
|
||||
"",
|
||||
"### Latest summary",
|
||||
"",
|
||||
redact_text(inc.summary) or "(no summary)",
|
||||
"",
|
||||
"### Canonical next action",
|
||||
"",
|
||||
"Author: this incident is still firing — investigate under the "
|
||||
"normal Gitea workflow. This comment records observability "
|
||||
"recurrence only and changes no workflow state.",
|
||||
]
|
||||
)
|
||||
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 "")
|
||||
@@ -514,6 +586,9 @@ def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | N
|
||||
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
||||
|
||||
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
|
||||
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
|
||||
|
||||
|
||||
def reconcile_incident(
|
||||
db: ControlPlaneDB | None,
|
||||
@@ -523,6 +598,7 @@ def reconcile_incident(
|
||||
mapping: ProjectMapping | None = None,
|
||||
apply: bool = False,
|
||||
create_issue_fn: CreateIssueFn | None = None,
|
||||
comment_issue_fn: CommentIssueFn | None = None,
|
||||
force_gitea_issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reconcile one observation into incident_links + optional Gitea issue.
|
||||
@@ -531,6 +607,11 @@ def reconcile_incident(
|
||||
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
||||
|
||||
When an existing link is reused and the provider reports *new* events,
|
||||
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
|
||||
Gitea issue (AC4). Dry runs never comment, and a missing
|
||||
``comment_issue_fn`` withholds the comment without failing the link.
|
||||
|
||||
Never creates control-plane ``work_items`` for raw incidents.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
@@ -549,6 +630,7 @@ def reconcile_incident(
|
||||
"gitea_issue": None,
|
||||
"action": None,
|
||||
"mapping": None,
|
||||
"recurrence_comment": None,
|
||||
"substrate": "control_plane_db.incident_links",
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
@@ -652,10 +734,14 @@ def reconcile_incident(
|
||||
# --- apply path ---
|
||||
issue_number: int | None = None
|
||||
created = False
|
||||
recurrence: tuple[bool, str] | None = None
|
||||
if existing:
|
||||
issue_number = int(existing["gitea_issue_number"])
|
||||
action = "updated_existing_link"
|
||||
outcome = OUTCOME_UPDATED
|
||||
# Compare against the pre-upsert link row: the upsert below overwrites
|
||||
# event_count/last_seen, which would erase the recurrence signal.
|
||||
recurrence = incident_recurred(existing, inc)
|
||||
elif force_gitea_issue_number is not None:
|
||||
issue_number = int(force_gitea_issue_number)
|
||||
action = "link_explicit_issue"
|
||||
@@ -729,6 +815,63 @@ def reconcile_incident(
|
||||
}
|
||||
return base
|
||||
|
||||
# AC4: continued provider events post a recurrence comment on the linked
|
||||
# Gitea issue. The durable incident_links row is already written above, so
|
||||
# a comment failure never rolls back or blocks the mapping — the next scan
|
||||
# retries while the link stays authoritative.
|
||||
if outcome == OUTCOME_UPDATED and recurrence is not None:
|
||||
recurred, why = recurrence
|
||||
if not recurred:
|
||||
base["recurrence_comment"] = {"posted": False, "reason": why}
|
||||
elif comment_issue_fn is None:
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
"no comment_issue_fn supplied; recurrence comment withheld "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
comment_res = comment_issue_fn(
|
||||
issue_number,
|
||||
build_recurrence_comment_body(inc, existing, reason=why),
|
||||
inc.gitea_org,
|
||||
inc.gitea_repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - never break the link write
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
f"recurrence comment failed: {redact_text(exc)} "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
posted = (
|
||||
bool(comment_res.get("success"))
|
||||
if isinstance(comment_res, dict)
|
||||
else bool(comment_res)
|
||||
)
|
||||
base["recurrence_comment"] = {
|
||||
"posted": posted,
|
||||
"recurrence_basis": why,
|
||||
"gitea_issue_number": issue_number,
|
||||
"comment_id": (
|
||||
comment_res.get("comment_id")
|
||||
if isinstance(comment_res, dict)
|
||||
else None
|
||||
),
|
||||
}
|
||||
if posted:
|
||||
base["gitea_mutated"] = True
|
||||
elif isinstance(comment_res, dict):
|
||||
base["recurrence_comment"]["reasons"] = [
|
||||
redact_text(r) for r in (comment_res.get("reasons") or [])
|
||||
]
|
||||
|
||||
base["success"] = True
|
||||
base["performed"] = True
|
||||
base["db_mutated"] = True
|
||||
|
||||
@@ -85,6 +85,15 @@ def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
return re.search(pattern, name) is not None
|
||||
|
||||
|
||||
def branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
"""Public accessor for the exact issue-marker match (#753).
|
||||
|
||||
Dead-session lock recovery needs the same word-boundary matcher to detect
|
||||
ambiguous branch claims, so it is exposed rather than reached into.
|
||||
"""
|
||||
return _branch_carries_issue_marker(branch_name, issue_number)
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""Dead-session author issue-lock recovery (#753).
|
||||
|
||||
A durable author issue lock records the PID of the MCP session that took it.
|
||||
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
|
||||
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
|
||||
so every ownership check that requires a *live* lock fails closed.
|
||||
|
||||
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
|
||||
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
|
||||
base-equivalent to ``master``/``main``/``dev``, and a branch that already
|
||||
carries commits is ahead of its base by construction. The existing
|
||||
``assess_expired_lock_reclaim`` affordance does not apply either, because
|
||||
``assess_same_issue_lease_conflict`` only consults it once the lease has
|
||||
*expired* — a dead PID under an unexpired lease never reaches it.
|
||||
|
||||
This module is the pure evidence assessor for that one narrow case. It grants
|
||||
recovery only when every element of durable ownership still matches exactly and
|
||||
the recorded process is demonstrably dead. It never trusts caller assertions:
|
||||
every field is compared against durable lock state or live observation supplied
|
||||
by the caller. It performs no mutation and no network I/O.
|
||||
|
||||
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
||||
claims — only for a lock whose own prior record already proves the branch,
|
||||
worktree, head, and author.
|
||||
|
||||
#768 extends the head requirement from strict equality to "equal, or a strict
|
||||
descendant". Equality alone made remediation after a session death unreachable:
|
||||
recovery needs a clean worktree, the only sanctioned way to clean one without
|
||||
discarding work is to commit, and committing advances the head past the value
|
||||
recorded at lock time. A commit that strictly descends from the recorded head,
|
||||
on the same branch, in the same worktree, by the same claimant, preserves
|
||||
everything equality protected — the recorded head is still reachable, still an
|
||||
ancestor, still unmodified — so it is accepted, and nothing else is. The
|
||||
descendant fact is observed server-side by
|
||||
``issue_lock_worktree.read_head_ancestry`` and handed in as ``head_ancestry``;
|
||||
no caller can assert it.
|
||||
|
||||
#772 adds the remaining uncovered quadrant: a claim that was never published at
|
||||
all. Two recovery modes now exist, and they require different evidence because
|
||||
they are answering the same question against different available facts:
|
||||
|
||||
``published_owning_pr``
|
||||
The branch exists on the remote. Ownership is proven by comparing the local
|
||||
head against the remote/PR head — equal (#753) or a strict descendant
|
||||
(#768). This is the pre-existing behavior and is unchanged.
|
||||
|
||||
``unpublished_claim``
|
||||
The branch is absent from the remote and no PR claims it, so there is no
|
||||
head to compare against; that absence is the defining fact, not a degraded
|
||||
published case. Ownership is instead proven by the durable lock record
|
||||
(issue, branch, worktree, claimant, profile, dead PID) plus the local HEAD
|
||||
strictly descending from the base the branch was cut from, observed
|
||||
server-side by ``issue_lock_worktree.read_recorded_base`` and re-checked
|
||||
through ``base_ancestry``.
|
||||
|
||||
They cannot share one head-comparison implementation: the published path's
|
||||
comparison target does not exist in the unpublished case, and inventing one
|
||||
(defaulting to the base, say) would silently weaken the published path from
|
||||
"matches what was actually pushed" to "descends from some base". The modes are
|
||||
therefore selected by observed publication state and never by a caller — and
|
||||
critically, the absence of a remote head is never itself treated as permission:
|
||||
every identity, profile, branch, worktree, cleanliness, liveness, and competing
|
||||
-claim check still applies in full.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from issue_lock_store import is_process_alive
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
# Outcome values
|
||||
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
|
||||
NO_CANDIDATE = "NO_CANDIDATE"
|
||||
REFUSED = "REFUSED"
|
||||
|
||||
# Durable fields a lock must carry before it can be considered at all.
|
||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||
|
||||
# How the clean local head relates to the head recorded at lock time (#768).
|
||||
HEAD_RELATION_EQUAL = "equal"
|
||||
HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant"
|
||||
# #772: an unpublished claim has no recorded head to compare against at all, so
|
||||
# its head is measured against the base the branch was cut from instead.
|
||||
HEAD_RELATION_DESCENDS_FROM_BASE = "descends_from_recorded_base"
|
||||
|
||||
# Which body of evidence a recovery was decided on (#772 AC10). These are not
|
||||
# interchangeable: a published claim proves ownership against a remote/PR head,
|
||||
# an unpublished one against the recorded base plus durable lock state. They
|
||||
# cannot share a single head-comparison implementation because the unpublished
|
||||
# case has no head to compare — that absence is the defining fact, not a
|
||||
# degraded version of the published case.
|
||||
RECOVERY_MODE_PUBLISHED_OWNING_PR = "published_owning_pr"
|
||||
RECOVERY_MODE_UNPUBLISHED_CLAIM = "unpublished_claim"
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
claimant = lock.get("claimant")
|
||||
if not isinstance(claimant, Mapping):
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||
|
||||
|
||||
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||
pid = lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock.get("pid")
|
||||
return pid
|
||||
|
||||
|
||||
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
"""Names of durable fields that are missing or unusable."""
|
||||
missing: list[str] = []
|
||||
for field in REQUIRED_LOCK_FIELDS:
|
||||
if not _text(lock.get(field)):
|
||||
missing.append(field)
|
||||
pid = _recorded_pid(lock)
|
||||
if pid is None or _text(pid) == "":
|
||||
missing.append("session_pid/pid")
|
||||
else:
|
||||
try:
|
||||
if int(pid) <= 0:
|
||||
missing.append("session_pid/pid")
|
||||
except (TypeError, ValueError):
|
||||
missing.append("session_pid/pid")
|
||||
return missing
|
||||
|
||||
|
||||
def _assess_strict_descendant(
|
||||
head_ancestry: Mapping[str, Any] | None,
|
||||
*,
|
||||
recorded_head: str,
|
||||
local_head: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Is ``local_head`` a proven strict descendant of ``recorded_head`` (#768)?
|
||||
|
||||
``head_ancestry`` is the server-side git observation from
|
||||
``issue_lock_worktree.read_head_ancestry``. Its own ``ancestor_sha`` /
|
||||
``descendant_sha`` are re-checked against the heads this assessment is
|
||||
actually reasoning about, so a probe taken for some other pair of commits —
|
||||
stale, mismatched, or hand-built — can never authorize a waiver.
|
||||
|
||||
Returns ``(proven, notes)``. Notes name the exact missing element so a
|
||||
refused caller sees why, never a bare "unproven".
|
||||
"""
|
||||
if not isinstance(head_ancestry, Mapping):
|
||||
return False, [
|
||||
"no server-derived ancestry observation was available; a local head "
|
||||
"that differs from the recorded head cannot be accepted"
|
||||
]
|
||||
|
||||
notes: list[str] = []
|
||||
probe_ancestor = _text(head_ancestry.get("ancestor_sha"))
|
||||
probe_descendant = _text(head_ancestry.get("descendant_sha"))
|
||||
if probe_ancestor != recorded_head or probe_descendant != local_head:
|
||||
return False, [
|
||||
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||
f"{probe_descendant or 'unknown'}, not the heads under assessment "
|
||||
f"({recorded_head} -> {local_head})"
|
||||
]
|
||||
if not head_ancestry.get("probe_ok"):
|
||||
notes.extend(
|
||||
list(head_ancestry.get("reasons") or [])
|
||||
or ["ancestry probe did not complete; ancestry unproven"]
|
||||
)
|
||||
return False, notes
|
||||
if not head_ancestry.get("ancestor_present"):
|
||||
return False, [
|
||||
f"recorded head {recorded_head} is no longer reachable; a rewritten "
|
||||
"or force-moved head cannot be recovered"
|
||||
]
|
||||
if not head_ancestry.get("is_strict_descendant"):
|
||||
notes.extend(
|
||||
list(head_ancestry.get("reasons") or [])
|
||||
or [
|
||||
f"local head {local_head} is not a strict descendant of the "
|
||||
f"recorded head {recorded_head}"
|
||||
]
|
||||
)
|
||||
return False, notes
|
||||
|
||||
proof = _text(head_ancestry.get("proof")) or (
|
||||
f"{recorded_head} is an ancestor of {local_head}"
|
||||
)
|
||||
return True, [
|
||||
f"local head {local_head} strictly descends from recorded head "
|
||||
f"{recorded_head} ({proof})"
|
||||
]
|
||||
|
||||
|
||||
def _assess_base_descendancy(
|
||||
base_ancestry: Mapping[str, Any] | None,
|
||||
*,
|
||||
recorded_base: str,
|
||||
local_head: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Is ``local_head`` a proven strict descendant of ``recorded_base`` (#772)?
|
||||
|
||||
The unpublished-claim analogue of ``_assess_strict_descendant``. The
|
||||
comparison target is the base the branch was cut from — observed server-side
|
||||
by ``issue_lock_worktree.read_recorded_base`` — rather than a remote or PR
|
||||
head, because an unpublished claim has neither.
|
||||
|
||||
The probe's own endpoints are re-checked against the values under
|
||||
assessment, so an observation taken for some other pair of commits cannot
|
||||
authorize recovery. Equality is refused: a HEAD that merely equals its base
|
||||
carries no committed work, and that is the ordinary base-equivalent case the
|
||||
normal lock path already handles.
|
||||
"""
|
||||
if not isinstance(base_ancestry, Mapping):
|
||||
return False, [
|
||||
"no server-derived ancestry observation was available; an "
|
||||
"unpublished claim cannot be recovered without proving its HEAD "
|
||||
"descends from the recorded base"
|
||||
]
|
||||
|
||||
probe_ancestor = _text(base_ancestry.get("ancestor_sha"))
|
||||
probe_descendant = _text(base_ancestry.get("descendant_sha"))
|
||||
if probe_ancestor != recorded_base or probe_descendant != local_head:
|
||||
return False, [
|
||||
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||
f"{probe_descendant or 'unknown'}, not the commits under assessment "
|
||||
f"({recorded_base} -> {local_head})"
|
||||
]
|
||||
if not base_ancestry.get("probe_ok"):
|
||||
return False, (
|
||||
list(base_ancestry.get("reasons") or [])
|
||||
or ["ancestry probe did not complete; ancestry unproven"]
|
||||
)
|
||||
if not base_ancestry.get("ancestor_present"):
|
||||
return False, [
|
||||
f"recorded base {recorded_base} is no longer reachable; a rewritten "
|
||||
"or force-moved base cannot be recovered"
|
||||
]
|
||||
if not base_ancestry.get("is_strict_descendant"):
|
||||
return False, (
|
||||
list(base_ancestry.get("reasons") or [])
|
||||
or [
|
||||
f"local head {local_head} is not a strict descendant of the "
|
||||
f"recorded base {recorded_base}"
|
||||
]
|
||||
)
|
||||
|
||||
proof = _text(base_ancestry.get("proof")) or (
|
||||
f"{recorded_base} is an ancestor of {local_head}"
|
||||
)
|
||||
return True, [
|
||||
f"local head {local_head} strictly descends from recorded base "
|
||||
f"{recorded_base} ({proof})"
|
||||
]
|
||||
|
||||
|
||||
def assess_dead_session_lock_recovery(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
head_sha: str | None,
|
||||
remote_head_sha: str | None,
|
||||
pr_head_sha: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||
candidate_branches: Iterable[str] | None = None,
|
||||
current_pid: int | None = None,
|
||||
head_ancestry: Mapping[str, Any] | None = None,
|
||||
remote_branch_exists: bool | None = None,
|
||||
recorded_base_sha: str | None = None,
|
||||
base_ancestry: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a dead-session author lock may be natively recovered.
|
||||
|
||||
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
|
||||
(why it was refused, or the positive proof when sanctioned), and
|
||||
``evidence`` (a redaction-safe record for auditing).
|
||||
|
||||
``NO_CANDIDATE`` means no recovery was attempted at all — there is no
|
||||
existing lock, or the lock does not describe this issue. The caller must
|
||||
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
|
||||
candidate existed but the evidence did not agree; the caller fails closed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
evidence: dict[str, Any] = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
|
||||
if not existing_lock:
|
||||
return _result(
|
||||
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
|
||||
)
|
||||
|
||||
lock = dict(existing_lock)
|
||||
|
||||
# ── Candidate identification ────────────────────────────────────────────
|
||||
# Recovery only ever applies to a lock that already claims THIS issue.
|
||||
# Anything else is not a recovery candidate and must not be reinterpreted.
|
||||
if lock.get("issue_number") != issue_number:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
False,
|
||||
[
|
||||
f"existing lock targets issue #{lock.get('issue_number')}, "
|
||||
f"not #{issue_number}; not a recovery candidate"
|
||||
],
|
||||
evidence,
|
||||
)
|
||||
|
||||
# A malformed/incomplete durable record can never prove ownership.
|
||||
missing = _malformed_reasons(lock)
|
||||
if missing:
|
||||
return _result(
|
||||
REFUSED,
|
||||
False,
|
||||
[
|
||||
"durable lock record is incomplete and cannot prove ownership "
|
||||
f"(missing/unusable: {', '.join(missing)})"
|
||||
],
|
||||
evidence,
|
||||
)
|
||||
|
||||
recorded_pid = _recorded_pid(lock)
|
||||
evidence["prior_session_pid"] = recorded_pid
|
||||
evidence["replacement_session_pid"] = (
|
||||
current_pid if current_pid is not None else os.getpid()
|
||||
)
|
||||
|
||||
# ── Repository scope ────────────────────────────────────────────────────
|
||||
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
|
||||
actual = _text(lock.get(field))
|
||||
if actual != _text(expected):
|
||||
reasons.append(
|
||||
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
|
||||
)
|
||||
|
||||
# ── Branch identity ─────────────────────────────────────────────────────
|
||||
locked_branch = _text(lock.get("branch_name"))
|
||||
if locked_branch != _text(branch_name):
|
||||
reasons.append(
|
||||
f"lock branch '{locked_branch}' does not match requested "
|
||||
f"'{_text(branch_name)}'"
|
||||
)
|
||||
evidence["locked_branch"] = locked_branch
|
||||
|
||||
# The worktree must actually be sitting on the locked branch. Without this
|
||||
# a clean worktree parked elsewhere could stand in for the real work.
|
||||
checked_out = _text(current_branch)
|
||||
if not checked_out:
|
||||
reasons.append(
|
||||
"worktree is not on a named branch (detached HEAD); locked-branch "
|
||||
"occupancy could not be proven"
|
||||
)
|
||||
elif checked_out != locked_branch:
|
||||
reasons.append(
|
||||
f"worktree is on branch '{checked_out}', not the locked branch "
|
||||
f"'{locked_branch}'"
|
||||
)
|
||||
|
||||
# ── Worktree identity ───────────────────────────────────────────────────
|
||||
locked_worktree = _text(lock.get("worktree_path"))
|
||||
if not _same_realpath(locked_worktree, worktree_path):
|
||||
reasons.append(
|
||||
f"lock worktree '{locked_worktree}' does not match declared "
|
||||
f"'{_text(worktree_path)}'"
|
||||
)
|
||||
evidence["locked_worktree_path"] = locked_worktree
|
||||
|
||||
# ── Cleanliness (never waived) ──────────────────────────────────────────
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"worktree has tracked local edits; recovery requires a clean "
|
||||
f"worktree (dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
evidence["dirty_files"] = dirty_files
|
||||
|
||||
# ── Head agreement: local is the recorded head, or strictly descends it ──
|
||||
# The recorded head is what the remote branch still carries. A local head
|
||||
# equal to it is the #753 case. A local head that strictly descends from it
|
||||
# is the #768 case: the author committed remediation, which is the only way
|
||||
# to reach the clean worktree recovery itself demands.
|
||||
local_head = _text(head_sha)
|
||||
remote_head = _text(remote_head_sha)
|
||||
recorded_base = _text(recorded_base_sha)
|
||||
head_relation: str | None = None
|
||||
ancestry_proof: str | None = None
|
||||
if not local_head:
|
||||
reasons.append("local head SHA could not be determined")
|
||||
|
||||
# #772: which body of evidence applies is decided by observed publication
|
||||
# state, never by a caller. ``remote_branch_exists is False`` is a positive
|
||||
# server-side observation that the branch is absent from the remote — it is
|
||||
# not the same as "the head lookup failed", which must still fail closed.
|
||||
unpublished = remote_branch_exists is False and not remote_head
|
||||
recovery_mode = (
|
||||
RECOVERY_MODE_UNPUBLISHED_CLAIM if unpublished
|
||||
else RECOVERY_MODE_PUBLISHED_OWNING_PR
|
||||
)
|
||||
evidence["recovery_mode"] = recovery_mode
|
||||
evidence["remote_branch_exists"] = remote_branch_exists
|
||||
|
||||
if unpublished:
|
||||
# No remote branch: ownership is measured against the recorded base.
|
||||
# An open PR here is contradictory — a PR cannot exist without a remote
|
||||
# branch — so it is a mismatch, never a thing to reconcile.
|
||||
if _text(pr_head_sha) or pr_number is not None:
|
||||
reasons.append(
|
||||
f"branch '{locked_branch}' is absent from the remote yet PR "
|
||||
f"#{pr_number} claims it; publication state is contradictory"
|
||||
)
|
||||
if not recorded_base:
|
||||
reasons.append(
|
||||
f"recorded base for branch '{locked_branch}' could not be "
|
||||
"determined; an unpublished claim cannot be recovered without it"
|
||||
)
|
||||
if local_head and recorded_base:
|
||||
descends, notes = _assess_base_descendancy(
|
||||
base_ancestry,
|
||||
recorded_base=recorded_base,
|
||||
local_head=local_head,
|
||||
)
|
||||
if descends:
|
||||
head_relation = HEAD_RELATION_DESCENDS_FROM_BASE
|
||||
ancestry_proof = notes[0] if notes else None
|
||||
else:
|
||||
reasons.extend(notes)
|
||||
else:
|
||||
if not remote_head:
|
||||
reasons.append(
|
||||
f"remote head for branch '{locked_branch}' could not be determined"
|
||||
)
|
||||
if local_head and remote_head:
|
||||
if local_head == remote_head:
|
||||
head_relation = HEAD_RELATION_EQUAL
|
||||
else:
|
||||
descends, notes = _assess_strict_descendant(
|
||||
head_ancestry,
|
||||
recorded_head=remote_head,
|
||||
local_head=local_head,
|
||||
)
|
||||
if descends:
|
||||
head_relation = HEAD_RELATION_STRICT_DESCENDANT
|
||||
ancestry_proof = notes[0] if notes else None
|
||||
else:
|
||||
reasons.append(
|
||||
f"local head {local_head} does not match remote branch head "
|
||||
f"{remote_head}"
|
||||
)
|
||||
reasons.extend(notes)
|
||||
evidence["recorded_base"] = recorded_base or None
|
||||
evidence["local_head"] = local_head or None
|
||||
evidence["remote_head"] = remote_head or None
|
||||
# ``recorded_head`` is the head recovery is being measured against;
|
||||
# ``accepted_head`` is the head this recovery actually adopts. They differ
|
||||
# only in the descendant case, and downstream gates need both (#768 AC2/AC7).
|
||||
evidence["recorded_head"] = remote_head or None
|
||||
evidence["accepted_head"] = local_head or None
|
||||
evidence["head_relation"] = head_relation
|
||||
evidence["ancestry_proof"] = ancestry_proof
|
||||
|
||||
pr_head = _text(pr_head_sha)
|
||||
if pr_head:
|
||||
evidence["pr_head"] = pr_head
|
||||
evidence["pr_number"] = pr_number
|
||||
# In unpublished mode the presence of any PR was already refused above as
|
||||
# contradictory; re-stating it as a head mismatch would only obscure why.
|
||||
if not unpublished and local_head and pr_head != local_head:
|
||||
# A descendant recovery has not been published yet, so the open PR
|
||||
# legitimately still points at the recorded head. Any other
|
||||
# disagreement is a real mismatch.
|
||||
if not (
|
||||
head_relation == HEAD_RELATION_STRICT_DESCENDANT
|
||||
and remote_head
|
||||
and pr_head == remote_head
|
||||
):
|
||||
reasons.append(
|
||||
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||
f"{local_head}"
|
||||
)
|
||||
|
||||
# ── Author identity ─────────────────────────────────────────────────────
|
||||
claimant = _lock_claimant(lock)
|
||||
locked_identity = _text(claimant.get("username"))
|
||||
locked_profile = _text(claimant.get("profile"))
|
||||
evidence["locked_identity"] = locked_identity or None
|
||||
evidence["locked_profile"] = locked_profile or None
|
||||
if not locked_identity or not locked_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record a claimant identity/profile; "
|
||||
"author ownership could not be proven"
|
||||
)
|
||||
if not _text(identity) or not _text(profile):
|
||||
reasons.append(
|
||||
"active session identity/profile is unknown; author ownership "
|
||||
"could not be proven"
|
||||
)
|
||||
if locked_identity and _text(identity) and locked_identity != _text(identity):
|
||||
reasons.append(
|
||||
f"lock claimant '{locked_identity}' does not match active identity "
|
||||
f"'{_text(identity)}'"
|
||||
)
|
||||
if locked_profile and _text(profile) and locked_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"lock profile '{locked_profile}' does not match active profile "
|
||||
f"'{_text(profile)}'"
|
||||
)
|
||||
|
||||
# ── The defining condition: the recorded owner must be dead ─────────────
|
||||
prior_alive = is_process_alive(recorded_pid)
|
||||
evidence["prior_pid_alive"] = prior_alive
|
||||
if prior_alive:
|
||||
reasons.append(
|
||||
f"prior owner pid {recorded_pid} is still alive; this is not a "
|
||||
"dead-session recovery"
|
||||
)
|
||||
if current_pid is not None and recorded_pid is not None:
|
||||
try:
|
||||
if int(recorded_pid) == int(current_pid):
|
||||
reasons.append(
|
||||
"recorded pid is the current session; nothing to recover"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# ── Competing ownership ─────────────────────────────────────────────────
|
||||
competing: list[dict[str, Any]] = []
|
||||
for entry in competing_live_locks or ():
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
same_issue = entry.get("issue_number") == issue_number
|
||||
same_branch = _text(entry.get("branch_name")) == locked_branch
|
||||
if not (same_issue or same_branch):
|
||||
continue
|
||||
# The lock we are recovering is not competition with itself.
|
||||
if (
|
||||
same_issue
|
||||
and same_branch
|
||||
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
|
||||
):
|
||||
continue
|
||||
competing.append(
|
||||
{
|
||||
"issue_number": entry.get("issue_number"),
|
||||
"branch_name": entry.get("branch_name"),
|
||||
"worktree_path": entry.get("worktree_path"),
|
||||
"pid": entry.get("pid"),
|
||||
}
|
||||
)
|
||||
if competing:
|
||||
described = ", ".join(
|
||||
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
|
||||
)
|
||||
reasons.append(f"competing live lock or lease exists ({described})")
|
||||
evidence["competing_live_locks"] = competing
|
||||
|
||||
# ── Ambiguous branch claims ─────────────────────────────────────────────
|
||||
others = [
|
||||
name
|
||||
for name in (candidate_branches or ())
|
||||
if _text(name) and _text(name) != locked_branch
|
||||
]
|
||||
if others:
|
||||
reasons.append(
|
||||
"multiple branches claim this issue "
|
||||
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
|
||||
)
|
||||
evidence["other_candidate_branches"] = sorted(set(others))
|
||||
|
||||
if reasons:
|
||||
return _result(REFUSED, False, reasons, evidence)
|
||||
|
||||
# No disposition may be granted without a proven head relation. Every path
|
||||
# above that leaves it unset also records a reason, so this is a belt-and-
|
||||
# braces guard against a future path forgetting one (#772 AC4).
|
||||
if head_relation is None:
|
||||
return _result(
|
||||
REFUSED,
|
||||
False,
|
||||
["head relation to the recorded head or base was never proven"],
|
||||
evidence,
|
||||
)
|
||||
|
||||
proof = [
|
||||
f"durable lock for issue #{issue_number} matches branch "
|
||||
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
||||
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
||||
]
|
||||
if recovery_mode == RECOVERY_MODE_UNPUBLISHED_CLAIM:
|
||||
proof.append(
|
||||
f"branch '{locked_branch}' has no remote head and no open PR; "
|
||||
f"ownership proven against recorded base {recorded_base}"
|
||||
)
|
||||
if (
|
||||
head_relation
|
||||
in (HEAD_RELATION_STRICT_DESCENDANT, HEAD_RELATION_DESCENDS_FROM_BASE)
|
||||
and ancestry_proof
|
||||
):
|
||||
proof.append(ancestry_proof)
|
||||
return _result(RECOVERY_SANCTIONED, True, proof, evidence)
|
||||
|
||||
|
||||
def _result(
|
||||
outcome: str,
|
||||
sanctioned: bool,
|
||||
reasons: list[str],
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"recovery_sanctioned": sanctioned,
|
||||
"is_candidate": outcome != NO_CANDIDATE,
|
||||
"reasons": reasons,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def owning_pr_recovery_evidence(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Server-derived proof of the open PR a sanctioned recovery already owns (#755).
|
||||
|
||||
A dead-session recovery is, by construction, recovery of work that already
|
||||
has an open PR — so the duplicate-work gate's linked-open-PR blocker would
|
||||
otherwise discard every sanctioned recovery. This distils the completed
|
||||
assessment into the minimum evidence that gate needs to tell "the PR this
|
||||
lock already owns" apart from "a competing duplicate PR".
|
||||
|
||||
Returns ``None`` unless recovery was actually granted and the assessment's
|
||||
own evidence names exactly one owning PR whose head agrees with the heads
|
||||
the assessor accepted. Nothing here is caller-supplied: every field is
|
||||
copied from evidence the assessor built out of durable lock state plus live
|
||||
git/Gitea observation, so a caller cannot manufacture an exemption.
|
||||
|
||||
#768: a descendant recovery carries two heads. ``head_sha`` stays the head
|
||||
the open PR currently shows (the recorded head, since the remediation is not
|
||||
published yet) and ``accepted_head`` is the local descendant that
|
||||
publication will move it to. Downstream gates accept either, so the
|
||||
exemption survives the very push it exists to permit.
|
||||
"""
|
||||
if not isinstance(assessment, Mapping):
|
||||
return None
|
||||
if assessment.get("outcome") != RECOVERY_SANCTIONED:
|
||||
return None
|
||||
if not assessment.get("recovery_sanctioned"):
|
||||
return None
|
||||
|
||||
evidence = assessment.get("evidence") or {}
|
||||
branch_name = _text(evidence.get("locked_branch"))
|
||||
pr_head = _text(evidence.get("pr_head"))
|
||||
local_head = _text(evidence.get("local_head"))
|
||||
remote_head = _text(evidence.get("remote_head"))
|
||||
recorded_head = _text(evidence.get("recorded_head")) or remote_head
|
||||
accepted_head = _text(evidence.get("accepted_head")) or local_head
|
||||
relation = _text(evidence.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||
raw_pr_number = evidence.get("pr_number")
|
||||
|
||||
if raw_pr_number is None or not branch_name or not pr_head:
|
||||
return None
|
||||
# The assessor already required these to agree. Re-check, so a truncated or
|
||||
# hand-built evidence map can never authorize an exemption.
|
||||
if relation == HEAD_RELATION_EQUAL:
|
||||
if pr_head != local_head or pr_head != remote_head:
|
||||
return None
|
||||
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||
# The PR must still be at the recorded head, and the accepted head must
|
||||
# actually be a different commit — otherwise this is not a descendant.
|
||||
if not recorded_head or pr_head != recorded_head:
|
||||
return None
|
||||
if not accepted_head or accepted_head == recorded_head:
|
||||
return None
|
||||
if accepted_head != local_head:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(evidence.get("issue_number"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": recorded_head or None,
|
||||
"accepted_head": accepted_head or None,
|
||||
"head_relation": relation,
|
||||
}
|
||||
|
||||
|
||||
def recovered_owning_pr_from_lock(
|
||||
lock_record: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Rebuild owning-PR recovery evidence from a persisted lock (#768 AC2).
|
||||
|
||||
``gitea_lock_issue`` holds the live assessment only for the duration of the
|
||||
lock call. The commit, push, create-PR, and duplicate-assessment gates run
|
||||
later, in their own calls, and re-derive ownership from scratch — so an open
|
||||
PR that recovery already proved belongs to this author reappears there as
|
||||
competing duplicate work.
|
||||
|
||||
This reads the same proof back out of the durable ``dead_session_recovery``
|
||||
block that only the server writes, on a lock the caller must already own.
|
||||
It is a re-read of server-derived state, not a new assertion: a caller that
|
||||
could forge this could equally forge the lock file itself, which every other
|
||||
ownership gate already treats as authoritative.
|
||||
"""
|
||||
if not isinstance(lock_record, Mapping):
|
||||
return None
|
||||
record = lock_record.get("dead_session_recovery")
|
||||
if not isinstance(record, Mapping) or not record.get("recovered"):
|
||||
return None
|
||||
|
||||
branch_name = _text(record.get("branch_name")) or _text(
|
||||
lock_record.get("branch_name")
|
||||
)
|
||||
pr_head = _text(record.get("pr_head"))
|
||||
recorded_head = _text(record.get("recorded_head")) or _text(
|
||||
record.get("remote_head")
|
||||
)
|
||||
accepted_head = _text(record.get("accepted_head")) or _text(
|
||||
record.get("local_head")
|
||||
)
|
||||
relation = _text(record.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||
raw_pr_number = record.get("pr_number")
|
||||
raw_issue_number = lock_record.get("issue_number")
|
||||
|
||||
if raw_pr_number is None or raw_issue_number is None:
|
||||
return None
|
||||
if not branch_name or not pr_head:
|
||||
return None
|
||||
if relation == HEAD_RELATION_EQUAL:
|
||||
if accepted_head and accepted_head != pr_head:
|
||||
return None
|
||||
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||
if not recorded_head or pr_head != recorded_head:
|
||||
return None
|
||||
if not accepted_head or accepted_head == recorded_head:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(raw_issue_number)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": recorded_head or None,
|
||||
"accepted_head": accepted_head or None,
|
||||
"head_relation": relation,
|
||||
}
|
||||
|
||||
|
||||
def build_recovery_record(
|
||||
assessment: Mapping[str, Any],
|
||||
*,
|
||||
recovered_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6).
|
||||
|
||||
#768 AC7: a granted recovery records, atomically with the lock itself, both
|
||||
session identities, the head it was measured against, the head it adopted,
|
||||
how those two relate, and the ancestry proof — so a descendant recovery can
|
||||
be audited after the fact without re-running any probe.
|
||||
"""
|
||||
evidence = dict(assessment.get("evidence") or {})
|
||||
return {
|
||||
"recovered": True,
|
||||
"reason": "owning MCP session exited; durable ownership evidence matched",
|
||||
"recovered_at": recovered_at,
|
||||
"prior_session_pid": evidence.get("prior_session_pid"),
|
||||
"replacement_session_pid": evidence.get("replacement_session_pid"),
|
||||
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||
"branch_name": evidence.get("locked_branch"),
|
||||
"worktree_path": evidence.get("locked_worktree_path"),
|
||||
"recovery_mode": evidence.get("recovery_mode"),
|
||||
"remote_branch_exists": evidence.get("remote_branch_exists"),
|
||||
"recorded_base": evidence.get("recorded_base"),
|
||||
"local_head": evidence.get("local_head"),
|
||||
"remote_head": evidence.get("remote_head"),
|
||||
"recorded_head": evidence.get("recorded_head"),
|
||||
"accepted_head": evidence.get("accepted_head"),
|
||||
"head_relation": evidence.get("head_relation"),
|
||||
"ancestry_proof": evidence.get("ancestry_proof"),
|
||||
"pr_head": evidence.get("pr_head"),
|
||||
"pr_number": evidence.get("pr_number"),
|
||||
"identity": evidence.get("locked_identity"),
|
||||
"profile": evidence.get("locked_profile"),
|
||||
"proof": list(assessment.get("reasons") or []),
|
||||
}
|
||||
|
||||
|
||||
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||
"""Single fail-closed message for a refused recovery attempt."""
|
||||
reasons = list(assessment.get("reasons") or []) or [
|
||||
"dead-session lock recovery evidence did not agree"
|
||||
]
|
||||
return (
|
||||
"Dead-session issue-lock recovery refused: "
|
||||
+ "; ".join(reasons)
|
||||
+ " (fail closed)"
|
||||
)
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Exact-owner renewal of an expired author issue lease (#760).
|
||||
|
||||
An author issue lease carries an absolute wall-clock expiry stamped once at
|
||||
lock time. The PID recorded alongside it is the long-lived MCP daemon, not the
|
||||
authoring task, so a lease that expires while its daemon is still up is the
|
||||
ordinary case for any author task that outlives the TTL — not an anomaly.
|
||||
|
||||
Before this module, that case was unreachable.
|
||||
``issue_lock_store.assess_same_issue_lease_conflict`` computed same-owner
|
||||
evidence and then returned on the expired branch before consulting it, and
|
||||
``assess_expired_lock_reclaim`` only permits takeover on a dead PID or a
|
||||
missing worktree. An exact owner whose daemon is alive and whose worktree is
|
||||
present satisfied neither, so its own lock became permanently unmodifiable
|
||||
through sanctioned tools.
|
||||
|
||||
This module is the pure evidence assessor for that one narrow case. It answers
|
||||
a single question: may *this* session renew a lease it can prove it already
|
||||
owns? It performs no mutation and no network I/O, and it never trusts a caller
|
||||
assertion — every field is compared against durable lock state or a live
|
||||
observation supplied by the caller and gathered server-side.
|
||||
|
||||
Deliberate boundaries:
|
||||
|
||||
* **Renewal is not takeover.** A refusal here never widens what
|
||||
``assess_expired_lock_reclaim`` already allows; foreign expired locks keep
|
||||
requiring a dead PID or missing worktree (#760 AC11), and a *live* foreign
|
||||
lease stays non-recoverable by construction because only an expired lease is
|
||||
ever a candidate (AC12).
|
||||
* **PID liveness is never authorization.** A live recorded PID proves the
|
||||
daemon is up, nothing more. It is recorded as evidence and is neither
|
||||
necessary nor sufficient for renewal (AC16).
|
||||
* **Absolute expiry is preserved.** Renewal issues a new absolute expiry from
|
||||
the moment of the write. It does not introduce sliding heartbeat renewal,
|
||||
lease generations as fencing tokens, or a shared cross-role lifecycle — that
|
||||
is #790's scope and is deliberately not implemented here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from issue_lock_store import AUTHOR_ISSUE_WORK_LEASE, is_lease_expired, is_process_alive
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
# Outcome values.
|
||||
RENEWAL_SANCTIONED = "RENEWAL_SANCTIONED"
|
||||
NO_CANDIDATE = "NO_CANDIDATE"
|
||||
REFUSED = "REFUSED"
|
||||
|
||||
# Durable fields a lock must carry before it can be considered at all.
|
||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
claimant = lock.get("claimant")
|
||||
if not isinstance(claimant, Mapping):
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||
|
||||
|
||||
def _lock_lease(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
lease = lock.get("work_lease")
|
||||
return dict(lease) if isinstance(lease, Mapping) else {}
|
||||
|
||||
|
||||
def _lock_operation_type(lock: Mapping[str, Any]) -> str:
|
||||
lease = _lock_lease(lock)
|
||||
return _text(lease.get("operation_type")) or AUTHOR_ISSUE_WORK_LEASE
|
||||
|
||||
|
||||
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||
pid = lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock.get("pid")
|
||||
return pid
|
||||
|
||||
|
||||
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
"""Names of durable fields that are missing or unusable."""
|
||||
missing: list[str] = []
|
||||
for field in REQUIRED_LOCK_FIELDS:
|
||||
if not _text(lock.get(field)):
|
||||
missing.append(field)
|
||||
pid = _recorded_pid(lock)
|
||||
if pid is None or _text(pid) == "":
|
||||
missing.append("session_pid/pid")
|
||||
else:
|
||||
try:
|
||||
if int(pid) <= 0:
|
||||
missing.append("session_pid/pid")
|
||||
except (TypeError, ValueError):
|
||||
missing.append("session_pid/pid")
|
||||
return missing
|
||||
|
||||
|
||||
def _competing_lock_reasons(
|
||||
competing_live_locks: Iterable[Mapping[str, Any]] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
) -> list[str]:
|
||||
"""Live locks that would contend with this renewal (#760 AC7).
|
||||
|
||||
A live lock on the *same* issue cannot coexist with this expired lease, so
|
||||
any live entry naming this issue, branch, or worktree belongs to somebody
|
||||
else and refuses the renewal.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
for entry in competing_live_locks or ():
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
entry_issue = entry.get("issue_number")
|
||||
entry_branch = _text(entry.get("branch_name"))
|
||||
entry_worktree = _text(entry.get("worktree_path"))
|
||||
if entry_issue == issue_number:
|
||||
reasons.append(
|
||||
f"a live lock already exists for issue #{issue_number} "
|
||||
f"(pid {entry.get('pid')}); renewal would contend with it"
|
||||
)
|
||||
continue
|
||||
if entry_branch and entry_branch == _text(branch_name):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds branch "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if entry_worktree and _same_realpath(entry_worktree, worktree_path):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds worktree "
|
||||
f"'{worktree_path}'"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def assess_exact_owner_lease_renewal(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
current_branch: str | None = None,
|
||||
porcelain_status: str = "",
|
||||
worktree_exists: bool = False,
|
||||
head_sha: str | None = None,
|
||||
remote_head_sha: str | None = None,
|
||||
pr_head_sha: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||
candidate_branches: Sequence[str] | None = None,
|
||||
current_pid: int | None = None,
|
||||
now: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether an expired lease may be renewed by its exact owner.
|
||||
|
||||
Returns a disposition dict; it never raises and never mutates. A refusal
|
||||
withholds permission, leaving every pre-existing guard to fail closed
|
||||
exactly as before — this assessment can only ever *add* permission.
|
||||
|
||||
``NO_CANDIDATE`` means the situation is not an exact-owner renewal at all
|
||||
(no lock, different issue, different operation, or an unexpired lease) and
|
||||
the caller should carry on with its normal path. ``REFUSED`` means it looked
|
||||
like one but the evidence did not hold, and ``reasons`` names exactly what
|
||||
was missing.
|
||||
"""
|
||||
evidence: dict[str, Any] = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"operation_type": operation_type,
|
||||
"identity": identity,
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
def _result(outcome: str, reasons: list[str], **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"renewal_sanctioned": outcome == RENEWAL_SANCTIONED,
|
||||
"is_candidate": outcome in (RENEWAL_SANCTIONED, REFUSED),
|
||||
"reasons": reasons,
|
||||
"evidence": {**evidence, **extra},
|
||||
}
|
||||
|
||||
if not isinstance(existing_lock, Mapping) or not existing_lock:
|
||||
return _result(NO_CANDIDATE, ["no existing lock to renew"])
|
||||
|
||||
if existing_lock.get("issue_number") != issue_number:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lock is for issue #{existing_lock.get('issue_number')}, "
|
||||
f"not #{issue_number}"
|
||||
],
|
||||
)
|
||||
|
||||
existing_operation = _lock_operation_type(existing_lock)
|
||||
if existing_operation != operation_type:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lease operation '{existing_operation}' is not "
|
||||
f"'{operation_type}'"
|
||||
],
|
||||
)
|
||||
|
||||
# Only an *expired* lease is ever a renewal candidate. An unexpired lease —
|
||||
# live, or stale by dead PID — is somebody else's problem: the first needs no
|
||||
# renewal, and the second is #753's dead-session recovery. This is also what
|
||||
# makes a live foreign lease non-recoverable here (#760 AC12).
|
||||
if not is_lease_expired(existing_lock, now=now):
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
["lease has not expired; renewal does not apply"],
|
||||
)
|
||||
|
||||
malformed = _malformed_reasons(existing_lock)
|
||||
if malformed:
|
||||
return _result(
|
||||
REFUSED,
|
||||
["durable lock is missing or has unusable fields: " + ", ".join(malformed)],
|
||||
)
|
||||
|
||||
lease = _lock_lease(existing_lock)
|
||||
claimant = _lock_claimant(existing_lock)
|
||||
recorded_pid = _recorded_pid(existing_lock)
|
||||
prior_expires_at = _text(lease.get("expires_at"))
|
||||
|
||||
# #760 AC16: recorded purely as evidence. A live daemon PID is neither
|
||||
# necessary nor sufficient for renewal, and nothing below branches on it.
|
||||
recorded_pid_alive = is_process_alive(recorded_pid)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"prior_pid": recorded_pid,
|
||||
"prior_pid_alive": recorded_pid_alive,
|
||||
"prior_expires_at": prior_expires_at,
|
||||
"replacement_pid": current_pid,
|
||||
"recorded_claimant": claimant,
|
||||
"head_sha": head_sha,
|
||||
"remote_head_sha": remote_head_sha,
|
||||
"pr_head_sha": pr_head_sha,
|
||||
"pr_number": pr_number,
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
# ── AC3: exact ownership identity ──
|
||||
if _text(existing_lock.get("remote")) != _text(remote):
|
||||
reasons.append(
|
||||
f"recorded remote '{existing_lock.get('remote')}' does not match "
|
||||
f"'{remote}'"
|
||||
)
|
||||
if _text(existing_lock.get("org")) != _text(org):
|
||||
reasons.append(
|
||||
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
|
||||
)
|
||||
if _text(existing_lock.get("repo")) != _text(repo):
|
||||
reasons.append(
|
||||
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
|
||||
)
|
||||
if _text(existing_lock.get("branch_name")) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path):
|
||||
reasons.append(
|
||||
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
|
||||
f"match '{worktree_path}'"
|
||||
)
|
||||
|
||||
recorded_identity = _text(claimant.get("username"))
|
||||
recorded_profile = _text(claimant.get("profile"))
|
||||
if not recorded_identity or not recorded_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record both a claimant username and profile"
|
||||
)
|
||||
if recorded_identity and recorded_identity != _text(identity):
|
||||
reasons.append(
|
||||
f"recorded claimant '{recorded_identity}' does not match active "
|
||||
f"identity '{_text(identity) or 'unknown'}'"
|
||||
)
|
||||
if recorded_profile and recorded_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"recorded profile '{recorded_profile}' does not match active profile "
|
||||
f"'{_text(profile) or 'unknown'}'"
|
||||
)
|
||||
|
||||
# ── AC4: the registered worktree still exists, is on the branch, and is clean ──
|
||||
if not worktree_exists:
|
||||
reasons.append(f"declared worktree '{worktree_path}' does not exist")
|
||||
if _text(current_branch) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||
if dirty:
|
||||
reasons.append(
|
||||
"worktree has uncommitted tracked changes: " + ", ".join(sorted(dirty))
|
||||
)
|
||||
|
||||
# ── AC5/AC6: published heads must agree ──
|
||||
if not _text(head_sha):
|
||||
reasons.append("local head could not be observed")
|
||||
if not _text(remote_head_sha):
|
||||
reasons.append(
|
||||
"remote branch head could not be observed; an unpublished branch "
|
||||
"cannot prove exact-owner renewal"
|
||||
)
|
||||
if _text(head_sha) and _text(remote_head_sha) and head_sha != remote_head_sha:
|
||||
reasons.append(
|
||||
f"local head {head_sha} does not equal remote head {remote_head_sha}"
|
||||
)
|
||||
if pr_number is not None:
|
||||
if not _text(pr_head_sha):
|
||||
reasons.append(f"owning PR #{pr_number} head could not be observed")
|
||||
elif _text(head_sha) and pr_head_sha != head_sha:
|
||||
reasons.append(
|
||||
f"owning PR #{pr_number} head {pr_head_sha} does not equal local "
|
||||
f"head {head_sha}"
|
||||
)
|
||||
|
||||
# ── AC7: nothing else claims this work ──
|
||||
reasons.extend(
|
||||
_competing_lock_reasons(
|
||||
competing_live_locks,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=worktree_path,
|
||||
)
|
||||
)
|
||||
other_branches = [
|
||||
name
|
||||
for name in (candidate_branches or ())
|
||||
if _text(name) and _text(name) != _text(branch_name)
|
||||
]
|
||||
if other_branches:
|
||||
reasons.append(
|
||||
"other branches already carry this issue marker: "
|
||||
+ ", ".join(sorted(other_branches))
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return _result(REFUSED, reasons, **extra)
|
||||
|
||||
return _result(
|
||||
RENEWAL_SANCTIONED,
|
||||
[
|
||||
f"exact owner '{recorded_identity}' ({recorded_profile}) proved "
|
||||
f"ownership of issue #{issue_number} on branch '{branch_name}' from "
|
||||
f"worktree '{worktree_path}'; local, remote"
|
||||
+ (f", and PR #{pr_number}" if pr_number is not None else "")
|
||||
+ f" heads all equal {head_sha}; lease expired at "
|
||||
f"{prior_expires_at or 'unknown'}"
|
||||
],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def owning_pr_renewal_evidence(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Server-derived proof of the open PR a sanctioned renewal already owns.
|
||||
|
||||
The mirror of ``issue_lock_recovery.owning_pr_recovery_evidence`` (#755) for
|
||||
the renewal disposition. An exact-owner renewal of a published branch is, by
|
||||
construction, renewal of work that already has an open PR — so the
|
||||
duplicate-work gate's linked-open-PR blocker would otherwise discard every
|
||||
sanctioned renewal, exactly as it once discarded every sanctioned recovery.
|
||||
|
||||
Returns ``None`` unless renewal was actually granted and the evidence names
|
||||
one owning PR whose head agrees with both the local and remote heads the
|
||||
assessor accepted. Nothing is caller-supplied: every field is copied from
|
||||
evidence built out of durable lock state plus live git/Gitea observation.
|
||||
|
||||
Renewal has no descendant case — it requires the local, remote, and PR heads
|
||||
to be equal — so there is only one head to report.
|
||||
"""
|
||||
if not isinstance(assessment, Mapping):
|
||||
return None
|
||||
if assessment.get("outcome") != RENEWAL_SANCTIONED:
|
||||
return None
|
||||
if not assessment.get("renewal_sanctioned"):
|
||||
return None
|
||||
|
||||
evidence = assessment.get("evidence") or {}
|
||||
branch_name = _text(evidence.get("branch_name"))
|
||||
pr_head = _text(evidence.get("pr_head_sha"))
|
||||
local_head = _text(evidence.get("head_sha"))
|
||||
remote_head = _text(evidence.get("remote_head_sha"))
|
||||
raw_pr_number = evidence.get("pr_number")
|
||||
|
||||
if raw_pr_number is None or not branch_name or not pr_head:
|
||||
return None
|
||||
# The assessor already required these to agree. Re-check, so a truncated or
|
||||
# hand-built evidence map can never authorize an exemption.
|
||||
if pr_head != local_head or pr_head != remote_head:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(evidence.get("issue_number"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": pr_head,
|
||||
"accepted_head": pr_head,
|
||||
"head_relation": "equal",
|
||||
}
|
||||
|
||||
|
||||
def build_renewal_record(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
*,
|
||||
renewed_at: str,
|
||||
new_expires_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable audit record for a sanctioned renewal (#760 AC9).
|
||||
|
||||
Records both sides of the transition — prior PID and expiry, replacement PID
|
||||
and new expiry — so a renewed lock is never mistakable for an original
|
||||
claim, and so the evidence the waiver was granted on stays inspectable.
|
||||
"""
|
||||
data = dict(assessment or {})
|
||||
evidence = dict(data.get("evidence") or {})
|
||||
recorded_claimant = dict(evidence.get("recorded_claimant") or {})
|
||||
return {
|
||||
"renewed": bool(data.get("renewal_sanctioned")),
|
||||
"renewed_at": renewed_at,
|
||||
"prior_pid": evidence.get("prior_pid"),
|
||||
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||
"prior_expires_at": evidence.get("prior_expires_at"),
|
||||
"replacement_pid": evidence.get("replacement_pid"),
|
||||
"new_expires_at": new_expires_at,
|
||||
"identity": recorded_claimant.get("username"),
|
||||
"profile": recorded_claimant.get("profile"),
|
||||
"branch_name": evidence.get("branch_name"),
|
||||
"worktree_path": evidence.get("worktree_path"),
|
||||
"head_sha": evidence.get("head_sha"),
|
||||
"remote_head_sha": evidence.get("remote_head_sha"),
|
||||
"pr_head_sha": evidence.get("pr_head_sha"),
|
||||
"pr_number": evidence.get("pr_number"),
|
||||
"reason": "expired lease renewed by its exact recorded owner",
|
||||
"proof": list(data.get("reasons") or []),
|
||||
}
|
||||
|
||||
|
||||
def format_renewal_refusal(assessment: Mapping[str, Any] | None) -> str:
|
||||
"""One-line refusal summary for a blocked caller."""
|
||||
data = dict(assessment or {})
|
||||
reasons = list(data.get("reasons") or [])
|
||||
if not reasons:
|
||||
return "exact-owner lease renewal was not available (no evidence recorded)"
|
||||
return "exact-owner lease renewal refused: " + "; ".join(reasons)
|
||||
+68
-3
@@ -148,8 +148,38 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
def lock_generation(lock: dict[str, Any] | None) -> int:
|
||||
"""Monotonic write counter for a durable lock record (#772 AC5).
|
||||
|
||||
Absent or unusable values read as ``0`` so a lock written before generations
|
||||
existed still participates in compare-and-swap: its first recovery expects
|
||||
``0`` and writes ``1``.
|
||||
"""
|
||||
if not isinstance(lock, dict):
|
||||
return 0
|
||||
try:
|
||||
return int(lock.get("lock_generation") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bind_session_lock(
|
||||
lock_data: dict[str, Any],
|
||||
lock_dir: str | None = None,
|
||||
*,
|
||||
expected_generation: int | None = None,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session.
|
||||
|
||||
``expected_generation`` turns the write into a compare-and-swap (#772 AC5).
|
||||
Recovery decides it may take over a claim by reading the durable lock, but
|
||||
that read and this write are separate steps; without a CAS two replacement
|
||||
sessions can both observe the same dead owner, both pass assessment, and
|
||||
both write — the second silently clobbering the first. Passing the
|
||||
generation observed at assessment time makes exactly one of them win: the
|
||||
loser's expectation no longer matches and it fails closed.
|
||||
"""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
@@ -191,9 +221,24 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
# #772 AC5: compare-and-swap inside the same critical section that
|
||||
# already serializes writers, so the check and the write cannot be
|
||||
# separated by another session's successful recovery.
|
||||
current_generation = lock_generation(existing)
|
||||
if (
|
||||
expected_generation is not None
|
||||
and current_generation != expected_generation
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock generation changed: expected "
|
||||
f"{expected_generation}, found {current_generation}; another "
|
||||
"session already recovered or replaced this claim (fail closed)"
|
||||
)
|
||||
record["lock_generation"] = current_generation + 1
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
@@ -440,9 +485,19 @@ def assess_same_issue_lease_conflict(
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
renewal_sanctioned: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition.
|
||||
|
||||
``renewal_sanctioned`` is set only when
|
||||
``issue_lock_renewal.assess_exact_owner_lease_renewal`` has already proven,
|
||||
from the durable lock plus live server-side observation, that this session
|
||||
is the exact recorded owner of an *expired* lease (#760). It is never a
|
||||
caller-supplied parameter of any MCP tool (#760 AC14): the server computes
|
||||
it and passes it down. Left False, every pre-existing disposition is
|
||||
unchanged.
|
||||
"""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
@@ -463,6 +518,16 @@ def assess_same_issue_lease_conflict(
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
# #760 AC1/AC2: exact-owner renewal is a different disposition from
|
||||
# foreign takeover and is evaluated first. Before this, both branches
|
||||
# below returned unconditionally, so the same_owner allowance further
|
||||
# down was unreachable for every expired lease — an owner could never
|
||||
# renew its own lock once the wall clock passed, no matter how complete
|
||||
# its ownership evidence. Requires BOTH the locally recomputed
|
||||
# same_owner match and the server-proven renewal waiver; either alone is
|
||||
# insufficient.
|
||||
if same_owner and renewal_sanctioned:
|
||||
return None
|
||||
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
|
||||
if reclaim.get("reclaim_allowed"):
|
||||
# #601: expired + dead pid / missing worktree may be reclaimed
|
||||
|
||||
+236
-3
@@ -20,16 +20,208 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
def resolve_author_worktree_path(
|
||||
explicit: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
session_lock_worktree: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the author worktree path for lock/PR gates."""
|
||||
"""Resolve the author worktree path for lock/PR gates.
|
||||
|
||||
#618: prefer explicit path, then env, then the active issue lock worktree.
|
||||
Does not invent a branches/ worktree. Falling back to *project_root* is
|
||||
retained only for lock-time bootstrap when the process itself is already
|
||||
under branches/ or no binding exists yet (callers still fail closed via
|
||||
preflight / durable resolution before mutation).
|
||||
"""
|
||||
path = (explicit or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
|
||||
if not path:
|
||||
path = (session_lock_worktree or "").strip()
|
||||
if not path:
|
||||
path = project_root
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_head_ancestry(
|
||||
worktree_path: str,
|
||||
*,
|
||||
ancestor_sha: str | None,
|
||||
descendant_sha: str | None,
|
||||
) -> dict:
|
||||
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
|
||||
|
||||
Server-side git observation for dead-session lock recovery. The recovering
|
||||
author's only reachable clean-worktree state is one commit *ahead* of the
|
||||
head recorded at lock time, so recovery needs to know whether that commit
|
||||
extends the recorded head or replaces it.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing here is supplied
|
||||
by, or reachable from, an MCP caller (#768 AC6).
|
||||
|
||||
``ancestor_present`` proves the recorded head is still reachable, which is
|
||||
what separates an honest fast-forward from a rewritten or force-moved
|
||||
history: a rewritten recorded head leaves the object graph and the probe
|
||||
fails closed.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
ancestor = (ancestor_sha or "").strip()
|
||||
descendant = (descendant_sha or "").strip()
|
||||
result: dict = {
|
||||
"ancestor_sha": ancestor or None,
|
||||
"descendant_sha": descendant or None,
|
||||
"probe_ok": False,
|
||||
"ancestor_present": False,
|
||||
"descendant_present": False,
|
||||
"is_ancestor": False,
|
||||
"is_strict_descendant": False,
|
||||
"proof": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not ancestor or not descendant:
|
||||
result["reasons"].append(
|
||||
"ancestry probe requires a worktree path and both commit SHAs"
|
||||
)
|
||||
return result
|
||||
|
||||
def _present(sha: str) -> bool:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return res.returncode == 0
|
||||
|
||||
try:
|
||||
result["ancestor_present"] = _present(ancestor)
|
||||
result["descendant_present"] = _present(descendant)
|
||||
except OSError as exc: # git unavailable — fail closed, never assume
|
||||
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||
return result
|
||||
|
||||
if not result["ancestor_present"]:
|
||||
result["reasons"].append(
|
||||
f"recorded head {ancestor} is not reachable in '{path}'; history may "
|
||||
"have been rewritten or force-moved"
|
||||
)
|
||||
if not result["descendant_present"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} is not reachable in '{path}'"
|
||||
)
|
||||
if not (result["ancestor_present"] and result["descendant_present"]):
|
||||
return result
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
|
||||
if probe.returncode not in (0, 1):
|
||||
result["reasons"].append(
|
||||
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
|
||||
)
|
||||
return result
|
||||
|
||||
result["probe_ok"] = True
|
||||
result["is_ancestor"] = probe.returncode == 0
|
||||
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
|
||||
result["proof"] = (
|
||||
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
|
||||
f"-> exit {probe.returncode}"
|
||||
)
|
||||
if not result["is_ancestor"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} does not descend from recorded head {ancestor}"
|
||||
)
|
||||
elif not result["is_strict_descendant"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} equals the recorded head; no descendant "
|
||||
"recovery is involved"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_recorded_base(
|
||||
worktree_path: str,
|
||||
*,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Observe the base commit an unpublished claim was branched from (#772).
|
||||
|
||||
A published claim records its base implicitly: the remote branch head is the
|
||||
thing recovery measures against. An unpublished claim has no remote ref, so
|
||||
the base must be observed here, server-side, as the merge-base between the
|
||||
worktree HEAD and the base branch it was cut from.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing is supplied by, or
|
||||
reachable from, an MCP caller, so a caller cannot nominate a base that would
|
||||
make unrelated history look like a descendant (#772 AC1/AC4).
|
||||
|
||||
A HEAD with no common ancestor in any base branch yields ``probe_ok`` with no
|
||||
``base_sha``: unrelated history is reported as exactly that, never as a base.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
head = (head_sha or "").strip()
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
candidates = [*extra_bases, *sorted(bases)]
|
||||
result: dict = {
|
||||
"base_branch": None,
|
||||
"base_sha": None,
|
||||
"head_sha": head or None,
|
||||
"probe_ok": False,
|
||||
"candidates": candidates,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not head:
|
||||
result["reasons"].append(
|
||||
"recorded-base probe requires a worktree path and a HEAD sha"
|
||||
)
|
||||
return result
|
||||
|
||||
probed_any = False
|
||||
for candidate in candidates:
|
||||
name = (candidate or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", name, head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode not in (0, 1):
|
||||
# 0 = merge base found, 1 = no common ancestor. Anything else is a
|
||||
# failed probe (missing ref, broken repo) — try the next candidate.
|
||||
continue
|
||||
probed_any = True
|
||||
merge_base = (probe.stdout or "").strip()
|
||||
if probe.returncode == 0 and merge_base:
|
||||
result["base_branch"] = name
|
||||
result["base_sha"] = merge_base
|
||||
result["probe_ok"] = True
|
||||
return result
|
||||
|
||||
result["probe_ok"] = probed_any
|
||||
if probed_any:
|
||||
result["reasons"].append(
|
||||
f"HEAD {head} shares no common ancestor with any of "
|
||||
f"{_base_list(bases)}; history is unrelated to this repository's base"
|
||||
)
|
||||
else:
|
||||
result["reasons"].append(
|
||||
f"recorded-base probe could not run against any of {_base_list(bases)} "
|
||||
f"in '{path}'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
@@ -92,8 +284,33 @@ def assess_issue_lock_worktree(
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree.
|
||||
|
||||
``recovery_sanctioned`` is set only when ``issue_lock_recovery`` has already
|
||||
proven, from the durable lock itself, that this is a dead-session recovery of
|
||||
an existing claim (#753): same issue, branch, worktree, author, and head, with
|
||||
the recording process dead. In that one case the base-equivalence requirement
|
||||
is waived, because a branch that already carries the work is ahead of its base
|
||||
by construction and could never satisfy it. Every other precondition —
|
||||
notably worktree cleanliness — still applies unchanged, and brand-new issue
|
||||
claims keep the full base-equivalence requirement.
|
||||
|
||||
``renewal_sanctioned`` waives base-equivalence on exactly the same grounds
|
||||
for the other proven-ownership case (#760): ``issue_lock_renewal`` has shown
|
||||
that an *expired* lease is being renewed by its exact recorded owner — same
|
||||
remote, org, repo, issue, operation, branch, realpath-normalized worktree,
|
||||
claimant username and profile — with the local head matching the remote head
|
||||
and any owning PR head. Such a branch carries committed work for the same
|
||||
reason a recovered one does, so it can never be base-equivalent either.
|
||||
|
||||
Both waivers relax this one requirement and nothing else. Neither is
|
||||
caller-supplied: each is computed server-side from durable lock state plus
|
||||
live observation. With both False every precondition applies exactly as
|
||||
before.
|
||||
"""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
path = (worktree_path or "").strip()
|
||||
@@ -111,7 +328,14 @@ def assess_issue_lock_worktree(
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if base_equivalent is False:
|
||||
if recovery_sanctioned or renewal_sanctioned:
|
||||
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||
# against the durable lock record instead — by dead-session recovery
|
||||
# (#753) or by exact-owner renewal of an expired lease (#760). Every
|
||||
# other precondition above and below still applies; cleanliness in
|
||||
# particular is checked before this branch and is never waived.
|
||||
pass
|
||||
elif base_equivalent is False:
|
||||
reasons.append(
|
||||
"issue lock worktree must be base-equivalent to one of "
|
||||
f"{_base_list(bases)} before implementation work; inspected "
|
||||
@@ -139,6 +363,8 @@ def assess_issue_lock_worktree(
|
||||
inspected_git_root=inspected_git_root,
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
|
||||
|
||||
@@ -197,6 +423,8 @@ def _assessment(
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
@@ -208,6 +436,11 @@ def _assessment(
|
||||
"dirty_files": dirty_files,
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
"recovery_sanctioned": recovery_sanctioned,
|
||||
"renewal_sanctioned": renewal_sanctioned,
|
||||
# Either proven-ownership waiver relaxes base-equivalence; the two are
|
||||
# reported separately so an audit can tell which one applied.
|
||||
"base_equivalence_waived": bool(recovery_sanctioned or renewal_sanctioned),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
@@ -27,6 +27,128 @@ def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||
|
||||
|
||||
def _pr_links_issue(issue_number: int, pr: Mapping[str, Any]) -> bool:
|
||||
"""Same linkage rule ``claim_hb._linked_open_pr`` applies, per PR.
|
||||
|
||||
``_linked_open_pr`` only yields the *first* match, which cannot answer
|
||||
"is there exactly one linked PR?" — a question the owning-PR exemption
|
||||
below must answer before it can trust any of them.
|
||||
"""
|
||||
pattern = _issue_pattern(issue_number)
|
||||
head = (pr.get("head") or {}).get("ref") or ""
|
||||
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||
if pattern in head.lower():
|
||||
return True
|
||||
return (
|
||||
f"closes #{int(issue_number)}" in text
|
||||
or f"fixes #{int(issue_number)}" in text
|
||||
)
|
||||
|
||||
|
||||
def _all_linked_open_prs(
|
||||
issue_number: int, open_prs: list[dict]
|
||||
) -> list[Mapping[str, Any]]:
|
||||
return [pr for pr in (open_prs or []) if _pr_links_issue(issue_number, pr)]
|
||||
|
||||
|
||||
def _assess_owning_pr_exemption(
|
||||
issue_number: int,
|
||||
*,
|
||||
linked_open_prs: list[Mapping[str, Any]],
|
||||
locked_branch: str | None,
|
||||
recovered_owning_pr: Mapping[str, Any] | None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Is the linked open PR provably the one a sanctioned recovery owns (#755)?
|
||||
|
||||
``recovered_owning_pr`` is produced by
|
||||
``issue_lock_recovery.owning_pr_recovery_evidence`` from a completed
|
||||
server-side recovery assessment — it is never a caller-supplied field.
|
||||
Every element is re-checked here against the live PR list this gate was
|
||||
given, so a stale or partial token cannot widen the exemption.
|
||||
|
||||
Returns ``(exempt, diagnostic_reasons)``. Diagnostics are only emitted when
|
||||
a token was offered and rejected, so a blocked caller can see which element
|
||||
of ownership disagreed.
|
||||
"""
|
||||
if not recovered_owning_pr:
|
||||
return False, []
|
||||
|
||||
notes: list[str] = []
|
||||
token_issue = recovered_owning_pr.get("issue_number")
|
||||
token_pr = recovered_owning_pr.get("pr_number")
|
||||
token_branch = str(recovered_owning_pr.get("branch_name") or "").strip()
|
||||
token_head = str(recovered_owning_pr.get("head_sha") or "").strip()
|
||||
locked = (locked_branch or "").strip()
|
||||
|
||||
if token_issue is not None and int(token_issue) != int(issue_number):
|
||||
notes.append(
|
||||
f"recovery evidence is for issue #{token_issue}, not "
|
||||
f"#{issue_number} (no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
if not locked or not token_branch or locked != token_branch:
|
||||
notes.append(
|
||||
f"recovery evidence branch '{token_branch or 'unknown'}' does not "
|
||||
f"match the branch being locked '{locked or 'unknown'}' "
|
||||
"(no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
if len(linked_open_prs) != 1:
|
||||
numbers = ", ".join(
|
||||
f"#{pr.get('number')}" for pr in linked_open_prs
|
||||
) or "none"
|
||||
notes.append(
|
||||
f"{len(linked_open_prs)} open PRs link issue #{issue_number} "
|
||||
f"({numbers}); recovery may only own exactly one "
|
||||
"(no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
|
||||
only = linked_open_prs[0]
|
||||
head_obj = only.get("head") or {}
|
||||
only_number = only.get("number")
|
||||
only_ref = str(head_obj.get("ref") or "").strip()
|
||||
only_sha = str(head_obj.get("sha") or "").strip()
|
||||
|
||||
if token_pr is None or only_number is None or int(only_number) != int(token_pr):
|
||||
notes.append(
|
||||
f"linked open PR #{only_number} is not the recovered owning PR "
|
||||
f"#{token_pr} (no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
if only_ref != token_branch:
|
||||
notes.append(
|
||||
f"open PR #{only_number} head branch '{only_ref}' does not match "
|
||||
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
# #768: a descendant recovery is measured against the head the PR still
|
||||
# shows, then publishes the local descendant — so the live PR head is the
|
||||
# recorded head before that push and the accepted head after it. Both are
|
||||
# server-derived and name the same owned PR, so both are accepted; anything
|
||||
# else still fails closed.
|
||||
token_accepted = str(recovered_owning_pr.get("accepted_head") or "").strip()
|
||||
acceptable_heads = [head for head in (token_head, token_accepted) if head]
|
||||
if not acceptable_heads or not only_sha or only_sha not in acceptable_heads:
|
||||
notes.append(
|
||||
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
||||
f"match the recovered head {token_head or 'unknown'}"
|
||||
+ (
|
||||
f" or the accepted head {token_accepted}"
|
||||
if token_accepted and token_accepted != token_head
|
||||
else ""
|
||||
)
|
||||
+ " (no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
|
||||
return True, [
|
||||
f"open PR #{only_number} is the exact PR already owned by the "
|
||||
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
|
||||
f"head {only_sha}); not duplicate work"
|
||||
]
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
@@ -52,8 +174,15 @@ def assess_work_issue_duplicate_gate(
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
recovered_owning_pr: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
||||
"""Fail closed when duplicate work is already in flight for an issue.
|
||||
|
||||
``recovered_owning_pr`` (#755) is server-derived evidence that a sanctioned
|
||||
dead-session lock recovery already owns one specific open PR. It exempts
|
||||
*only* that exact PR from the linked-open-PR blocker; every other duplicate
|
||||
signal, and every mismatch, keeps failing closed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
@@ -61,12 +190,23 @@ def assess_work_issue_duplicate_gate(
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
linked = _linked_open_pr(issue_number, prs)
|
||||
linked_open_prs = _all_linked_open_prs(issue_number, prs)
|
||||
owning_pr_exempted = False
|
||||
exemption_notes: list[str] = []
|
||||
if linked:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
|
||||
issue_number,
|
||||
linked_open_prs=linked_open_prs,
|
||||
locked_branch=locked_branch,
|
||||
recovered_owning_pr=recovered_owning_pr,
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
if not owning_pr_exempted:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
)
|
||||
reasons.extend(exemption_notes)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
conflicting_branches = _matching_branches(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
@@ -122,6 +262,9 @@ def assess_work_issue_duplicate_gate(
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||
"linked_open_pr_count": len(linked_open_prs),
|
||||
"owning_pr_recovery_exempted": owning_pr_exempted,
|
||||
"owning_pr_recovery_notes": list(exemption_notes),
|
||||
"conflicting_branches": conflicting_branches,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
|
||||
+338
-17
@@ -24,12 +24,50 @@ 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
|
||||
|
||||
# When True, ``read_remote_master_head`` never performs ``git ls-remote`` unless
|
||||
# ``GITEA_TEST_LIVE_REMOTE_HEAD`` is set. Conftest enables this suite-wide so
|
||||
# feature worktrees (whose HEAD differs from live master) cannot flip legacy
|
||||
# runtime-context assertions to live_stale, and so unit tests never depend on
|
||||
# a live network (PR #788 F1/F2 / issue #610). Module-level (not env-only) so
|
||||
# ``patch.dict(os.environ, …, clear=True)`` cannot re-enable the probe.
|
||||
_HERMETIC_TEST_MODE: bool = False
|
||||
|
||||
|
||||
def _clear_remote_head_cache() -> None:
|
||||
"""Reset the live-remote head cache (test isolation / forced refresh)."""
|
||||
_REMOTE_HEAD_CACHE.clear()
|
||||
|
||||
|
||||
def set_hermetic_test_mode(enabled: bool) -> None:
|
||||
"""Enable or disable suite-wide hermetic live-remote reads (tests only)."""
|
||||
global _HERMETIC_TEST_MODE
|
||||
_HERMETIC_TEST_MODE = bool(enabled)
|
||||
_clear_remote_head_cache()
|
||||
|
||||
|
||||
def hermetic_test_mode() -> bool:
|
||||
"""Return whether hermetic live-remote reads are active."""
|
||||
return bool(_HERMETIC_TEST_MODE)
|
||||
|
||||
|
||||
# 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"
|
||||
# GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE -> opt a single test into a real ls-remote
|
||||
# even when hermetic mode is on (rare; prefer ENV_TEST_LIVE_REMOTE_HEAD).
|
||||
ENV_TEST_ALLOW_LIVE_REMOTE_PROBE = "GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
@@ -58,6 +96,75 @@ 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.
|
||||
|
||||
Under suite hermetic mode (``set_hermetic_test_mode(True)``, set by
|
||||
conftest) a missing override returns ``None`` without network I/O so
|
||||
feature-worktree test runs cannot observe live_stale against real master
|
||||
(PR #788 F1) and unit tests stay offline (F2). Opt out with an explicit
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` pin or ``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if _HERMETIC_TEST_MODE and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
# Hermetic default: live head unknown. live_stale stays False;
|
||||
# mutation_safe is False when live is unknown (documented #610 note).
|
||||
return None
|
||||
# Defense in depth: even without the module flag, never probe while pytest
|
||||
# is running unless the test opted into a real probe or set an override.
|
||||
if (os.environ.get("PYTEST_CURRENT_TEST") or "").strip() and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
return 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.
|
||||
|
||||
@@ -72,18 +179,38 @@ def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
def assess_master_parity(
|
||||
startup: dict | None,
|
||||
current_head: str | None,
|
||||
live_remote_head: str | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
Pure: all 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`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``determinable`` -- whether both local 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] = []
|
||||
@@ -91,32 +218,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
|
||||
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, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, 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, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, 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")
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, 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)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"restart_required": stale or live_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),
|
||||
}
|
||||
|
||||
@@ -130,11 +281,13 @@ 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; only a definitively stale server blocks.
|
||||
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.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
if assessment.get("stale") or assessment.get("live_stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
@@ -147,6 +300,10 @@ 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 "
|
||||
@@ -157,6 +314,45 @@ 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"):
|
||||
@@ -166,3 +362,128 @@ def format_parity(assessment: dict) -> str:
|
||||
if not assessment.get("determinable"):
|
||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target-repository parity (#739 F3)
|
||||
#
|
||||
# Everything above measures ONE dimension: the Gitea-Tools server's own
|
||||
# implementation commit, comparing the SHA this process was loaded from against
|
||||
# the SHA now on disk at PROJECT_ROOT. That is deliberate and is left untouched
|
||||
# — it is what proves the in-memory capability gates are current.
|
||||
#
|
||||
# It is not, however, a statement about the repository a cross-repository
|
||||
# namespace actually mutates. The assessment below is a separate, separately
|
||||
# labelled dimension for the configured canonical target repository. It never
|
||||
# feeds the mutation gate and never changes startup_head/current_head.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
||||
|
||||
|
||||
def _git_capture(root: str, *args: str) -> str | None:
|
||||
"""Run a read-only git command in *root*; ``None`` on any failure.
|
||||
|
||||
Deliberately does not honour ``GITEA_TEST_CURRENT_HEAD``: that override
|
||||
exists to pin the *server's* HEAD, and applying it here would make a target
|
||||
repository silently report the server's forced SHA.
|
||||
"""
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def assess_target_repository_parity(
|
||||
*,
|
||||
canonical_root: str | None,
|
||||
source: str | None,
|
||||
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
|
||||
) -> dict:
|
||||
"""Assess the configured cross-repository target checkout.
|
||||
|
||||
Reports the target's canonical root, repository identity, checked-out
|
||||
commit, and last-known remote master commit, plus whether the checkout is
|
||||
behind that ref. No network call is made: the remote side is read from the
|
||||
existing remote-tracking ref, so a target that has never been fetched is
|
||||
reported as indeterminate rather than guessed at.
|
||||
|
||||
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
||||
single-repository default has no second dimension to be stale about. A
|
||||
configured root that cannot be read is ``determinable=False`` with reasons.
|
||||
"""
|
||||
result = {
|
||||
"configured": bool(canonical_root),
|
||||
"canonical_repository_root": None,
|
||||
"source": source,
|
||||
"repository_slug": None,
|
||||
"checkout_head": None,
|
||||
"tracking_ref": tracking_ref,
|
||||
"remote_tracking_head": None,
|
||||
"determinable": False,
|
||||
"stale": False,
|
||||
"reasons": [],
|
||||
}
|
||||
if not canonical_root:
|
||||
result["determinable"] = True
|
||||
return result
|
||||
|
||||
result["canonical_repository_root"] = canonical_root
|
||||
if not os.path.isdir(canonical_root):
|
||||
result["reasons"].append(
|
||||
f"configured canonical repository root '{canonical_root}' "
|
||||
f"does not exist or is not a directory"
|
||||
)
|
||||
return result
|
||||
|
||||
toplevel = _git_capture(canonical_root, "rev-parse", "--show-toplevel")
|
||||
if not toplevel:
|
||||
result["reasons"].append(
|
||||
f"configured canonical repository root '{canonical_root}' "
|
||||
f"is not a git checkout"
|
||||
)
|
||||
return result
|
||||
|
||||
head = _git_capture(canonical_root, "rev-parse", "HEAD")
|
||||
if not head:
|
||||
result["reasons"].append(
|
||||
f"target repository HEAD could not be read at '{canonical_root}'"
|
||||
)
|
||||
return result
|
||||
result["checkout_head"] = head
|
||||
result["determinable"] = True
|
||||
|
||||
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
|
||||
if remote_url:
|
||||
# Local import keeps this module dependency-light for its startup role.
|
||||
import remote_repo_guard
|
||||
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
|
||||
if parsed:
|
||||
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
|
||||
if not result["repository_slug"]:
|
||||
result["reasons"].append(
|
||||
"target repository identity could not be derived from its git remote"
|
||||
)
|
||||
|
||||
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
||||
if not tracking_head:
|
||||
result["reasons"].append(
|
||||
f"remote-tracking ref '{tracking_ref}' is unknown in the target "
|
||||
f"checkout; target staleness is indeterminate (no fetch is "
|
||||
f"performed by this assessment)"
|
||||
)
|
||||
return result
|
||||
result["remote_tracking_head"] = tracking_head
|
||||
result["stale"] = tracking_head != head
|
||||
return result
|
||||
|
||||
+43
-15
@@ -19,6 +19,32 @@ print_banner() {
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_workflow_dashboard_help() {
|
||||
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
|
||||
printf 'Read-only operational view (#605). Does NOT assign work.\n'
|
||||
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
|
||||
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
|
||||
printf ' gitea_workflow_dashboard(\n'
|
||||
printf ' remote=\"prgs\",\n'
|
||||
printf ' org=\"Scaled-Tech-Consulting\",\n'
|
||||
printf ' repo=\"Gitea-Tools\",\n'
|
||||
printf ' )\n\n'
|
||||
printf 'Returns machine-readable sections:\n'
|
||||
printf ' - open_pr_queue / open_issue_queue\n'
|
||||
printf ' - active_leases_by_role / stale_or_expired_leases\n'
|
||||
printf ' - terminal_review_lock\n'
|
||||
printf ' - blocked_items (never presented as safe)\n'
|
||||
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
|
||||
printf ' - discussion_issues / controller_needed\n'
|
||||
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
|
||||
printf ' - human_summary (copy-friendly multi-line text)\n\n'
|
||||
printf 'Safety:\n'
|
||||
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
|
||||
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
|
||||
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
@@ -241,25 +267,27 @@ main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
|
||||
printf ' 3) Author workflow prompts\n'
|
||||
printf ' 4) Reviewer workflow prompts\n'
|
||||
printf ' 5) Merger workflow prompts\n'
|
||||
printf ' 6) Reconciler workflow prompts\n'
|
||||
printf ' 7) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 8) Proxmox deployment menu placeholder\n'
|
||||
printf ' 9) Create Proxmox LXC placeholder\n'
|
||||
printf ' t) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
2) show_workflow_dashboard_help ;;
|
||||
3) show_author_prompts ;;
|
||||
4) show_reviewer_prompts ;;
|
||||
5) show_merger_prompts ;;
|
||||
6) show_reconciler_prompts ;;
|
||||
7) show_onboarding_prompt ;;
|
||||
8|9) show_proxmox_placeholder ;;
|
||||
t|T|tests) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
|
||||
+224
-10
@@ -36,6 +36,20 @@ KIND_REVIEW_DRAFT = "review_draft"
|
||||
# other session proofs; a contaminated session fails closed on gated mutations
|
||||
# until a reconciler audits and clears it.
|
||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||
# Durable marker set when a worker session manually kills MCP daemon processes
|
||||
# instead of using a sanctioned reconnect/restart path (#630). Same shape and
|
||||
# same reconciler-only clear as the #671 marker above; kept as its own kind so
|
||||
# an audit can tell the two contamination classes apart.
|
||||
KIND_RUNTIME_RECOVERY_CONTAMINATION = "runtime_recovery_contamination"
|
||||
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
||||
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||
# pid + session id) for the guarded lease-cleanup path. Never a substitute
|
||||
# for the in-session lease: mutation gates ignore it.
|
||||
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
|
||||
# Durable audit record written whenever the daemon's sanctioned stale-binding
|
||||
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
|
||||
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
|
||||
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||
@@ -46,12 +60,26 @@ KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
|
||||
|
||||
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||
#
|
||||
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
|
||||
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
|
||||
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
|
||||
# run #332 protections still apply once the ledger is loadable. Other session
|
||||
# kinds (workflow load, drafts, etc.) remain TTL-bound.
|
||||
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||
{
|
||||
KIND_DECISION_LOCK,
|
||||
KIND_DECISION_LOCK_ARCHIVE,
|
||||
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||
KIND_REVIEWER_SESSION_LEASE,
|
||||
KIND_STALE_BINDING_RECOVERY,
|
||||
# #630: contamination must not expire into cleanliness. A TTL-bound
|
||||
# marker would let a contaminated session self-clear by waiting, which
|
||||
# defeats the reconciler-only clear the gate depends on.
|
||||
KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -131,6 +159,7 @@ def state_key(
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
@@ -138,17 +167,20 @@ def state_key(
|
||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||
inside the payload and validated on load (#559), which lets a later daemon
|
||||
process recover state without already knowing the remote argument.
|
||||
|
||||
*instance_id* extends the key for kinds where one-per-profile collides —
|
||||
concurrent same-profile sessions each own their reviewer-lease shadow
|
||||
(#702 F5), keyed by their lease session id so a later heartbeat can never
|
||||
overwrite or misattribute another session's crash evidence.
|
||||
"""
|
||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
parts = [kind, profile_identity or "unknown-profile"]
|
||||
instance = (instance_id or "").strip()
|
||||
if instance:
|
||||
parts.append(instance)
|
||||
return "-".join(_sanitize_segment(part) for part in parts)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
@@ -159,6 +191,7 @@ def state_file_path(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
@@ -167,6 +200,7 @@ def state_file_path(
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
@@ -245,6 +279,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
|
||||
"""True when a durable record must outlive the generic session-state TTL."""
|
||||
if not record and not kind:
|
||||
return False
|
||||
record_kind = ((record or {}).get("kind") or kind or "").strip()
|
||||
if record_kind in RECOVERY_CRITICAL_KINDS:
|
||||
return True
|
||||
return bool((record or {}).get("recovery_critical"))
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
@@ -288,9 +332,7 @@ def identity_match_reasons(
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
kind = (record.get("kind") or "").strip()
|
||||
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
|
||||
record.get("recovery_critical")
|
||||
)
|
||||
ttl_exempt = is_recovery_critical_record(record, kind=kind)
|
||||
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
@@ -300,6 +342,113 @@ def identity_match_reasons(
|
||||
return reasons
|
||||
|
||||
|
||||
def inspect_state_envelope(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
|
||||
|
||||
Does **not** apply identity/TTL rejection to the returned presence flags.
|
||||
Callers use this to distinguish:
|
||||
* no file on disk
|
||||
* file present but TTL would reject a non-critical kind
|
||||
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
|
||||
Never mutates files. Never returns secrets.
|
||||
"""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"kind": kind,
|
||||
"profile_identity": profile,
|
||||
"path_basename": os.path.basename(path),
|
||||
"on_disk": False,
|
||||
"has_payload": False,
|
||||
"recorded_at": None,
|
||||
"updated_at": None,
|
||||
"age_hours": None,
|
||||
"ttl_hours": ttl_hours(),
|
||||
"age_exceeds_default_ttl": False,
|
||||
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
|
||||
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
|
||||
"would_ttl_reject": False,
|
||||
"identity_reasons": [],
|
||||
"summary": "no session-state file on disk",
|
||||
}
|
||||
if not path or not os.path.exists(path):
|
||||
return result
|
||||
result["on_disk"] = True
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
result["summary"] = "session-state file present but unreadable or empty"
|
||||
return result
|
||||
payload = envelope.get("payload")
|
||||
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
|
||||
result["has_payload"] = isinstance(payload, dict)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
"recovery_critical",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
if not merged.get("kind"):
|
||||
merged["kind"] = kind
|
||||
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
|
||||
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
|
||||
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
|
||||
if recorded_at is not None:
|
||||
age = _now_utc() - recorded_at
|
||||
age_hours = age.total_seconds() / 3600.0
|
||||
result["age_hours"] = age_hours
|
||||
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
|
||||
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
|
||||
result["recovery_critical"] = ttl_exempt
|
||||
result["ttl_exempt"] = ttl_exempt
|
||||
identity_reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
result["identity_reasons"] = list(identity_reasons)
|
||||
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
|
||||
if result["has_payload"] and ttl_exempt:
|
||||
result["summary"] = (
|
||||
"recovery-critical session-state present on disk and TTL-exempt; "
|
||||
"load via load_state for full payload"
|
||||
)
|
||||
elif result["has_payload"] and result["would_ttl_reject"]:
|
||||
result["summary"] = (
|
||||
"session-state file present on disk but generic TTL would reject load "
|
||||
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
|
||||
)
|
||||
elif result["has_payload"]:
|
||||
result["summary"] = "session-state file present and within TTL / identity gates"
|
||||
else:
|
||||
result["summary"] = "session-state file present without a dict payload"
|
||||
return result
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
kind: str,
|
||||
@@ -308,6 +457,7 @@ def load_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
@@ -318,6 +468,7 @@ def load_state(
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -363,6 +514,7 @@ def save_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
@@ -375,6 +527,11 @@ def save_state(
|
||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||
key_org = org if org is not None else (payload or {}).get("org")
|
||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||
key_instance = (
|
||||
(instance_id or "").strip()
|
||||
or str((payload or {}).get("instance_id") or "").strip()
|
||||
or None
|
||||
)
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
@@ -384,6 +541,7 @@ def save_state(
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
instance_id=key_instance,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -413,6 +571,8 @@ def save_state(
|
||||
body["repo"] = key_repo
|
||||
# Stamp session-state authority used for this write (#695 AC2 / AC6).
|
||||
body.setdefault("session_state_dir", root)
|
||||
if key_instance:
|
||||
body["instance_id"] = key_instance
|
||||
try:
|
||||
import mcp_daemon_guard
|
||||
|
||||
@@ -446,6 +606,8 @@ def save_state(
|
||||
"transport": body.get("transport"),
|
||||
"payload": body,
|
||||
}
|
||||
if key_instance:
|
||||
envelope["instance_id"] = key_instance
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
@@ -458,6 +620,7 @@ def clear_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
@@ -467,8 +630,59 @@ def clear_state(
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
def list_states(
|
||||
*,
|
||||
kind: str,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Enumerate durable records of *kind* across all instance keys.
|
||||
|
||||
Crash recovery cannot know which session id left a shadow behind, so it
|
||||
must be able to enumerate every record of a kind (#702 F5). Identity
|
||||
checks (profile / TTL policy) apply per record exactly as in
|
||||
:func:`load_state`; records that fail closed are omitted.
|
||||
"""
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
if not root or not os.path.isdir(root):
|
||||
return []
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
return []
|
||||
for name in names:
|
||||
if not name.endswith(".json") or name.startswith("."):
|
||||
continue
|
||||
envelope = _read_json(os.path.join(root, name))
|
||||
if not envelope or envelope.get("kind") != kind:
|
||||
continue
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
if identity_match_reasons(merged, profile_identity=profile):
|
||||
continue
|
||||
results.append(merged)
|
||||
return results
|
||||
|
||||
def list_decision_lock_profile_identities(
|
||||
state_dir: str | None = None,
|
||||
) -> list[str]:
|
||||
|
||||
+185
-2
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -38,6 +39,9 @@ REASON_CONFIG_ERROR = "config_error"
|
||||
REASON_INTERNAL_ERROR = "internal_error"
|
||||
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||
REASON_HTTP_ERROR = "http_error"
|
||||
# Typed structured errors for previously-opaque raw mutation failures.
|
||||
REASON_PREFLIGHT_ORDER = "preflight_order_violation"
|
||||
REASON_MALFORMED_RESPONSE = "malformed_response"
|
||||
|
||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||
@@ -45,6 +49,7 @@ ERROR_CLASS_NETWORK = "network"
|
||||
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||
ERROR_CLASS_INTERNAL = "internal"
|
||||
ERROR_CLASS_UPSTREAM = "upstream"
|
||||
ERROR_CLASS_PRECONDITION = "precondition"
|
||||
|
||||
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||
# Keychain contents, tokens, or arbitrary exception text.
|
||||
@@ -62,12 +67,128 @@ FIXED_MESSAGES: dict[str, str] = {
|
||||
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||
REASON_PREFLIGHT_ORDER: (
|
||||
"Mutation blocked: pre-flight order violation (fail closed)"
|
||||
),
|
||||
REASON_MALFORMED_RESPONSE: "Malformed response from Gitea",
|
||||
}
|
||||
|
||||
# Error class for a self-declared safe reason code (``gitea_reason_code``).
|
||||
_REASON_ERROR_CLASS: dict[str, str] = {
|
||||
REASON_AUTH_FAILED: ERROR_CLASS_AUTHENTICATION,
|
||||
REASON_AUTH_INVALID_TOKEN: ERROR_CLASS_AUTHENTICATION,
|
||||
REASON_AUTHZ_INSUFFICIENT_SCOPE: ERROR_CLASS_AUTHORIZATION,
|
||||
REASON_AUTHZ_DENIED: ERROR_CLASS_AUTHORIZATION,
|
||||
REASON_NETWORK_ERROR: ERROR_CLASS_NETWORK,
|
||||
REASON_CONFIG_ERROR: ERROR_CLASS_CONFIGURATION,
|
||||
REASON_UPSTREAM_UNAVAILABLE: ERROR_CLASS_UPSTREAM,
|
||||
REASON_HTTP_ERROR: ERROR_CLASS_INTERNAL,
|
||||
REASON_PREFLIGHT_ORDER: ERROR_CLASS_PRECONDITION,
|
||||
REASON_MALFORMED_RESPONSE: ERROR_CLASS_INTERNAL,
|
||||
REASON_INTERNAL_ERROR: ERROR_CLASS_INTERNAL,
|
||||
}
|
||||
|
||||
# Bounds for the safe diagnostic fields added to the ``internal_error`` path.
|
||||
_DETAIL_LIMIT = 200
|
||||
_CLASS_LIMIT = 120
|
||||
_STAGE_LIMIT = 64
|
||||
|
||||
# Absolute-path prefixes stripped from diagnostic detail (never leak layout).
|
||||
_ABS_PATH_RE = re.compile(r"/(?:Users|home|private|var|tmp|opt|etc|root)/[^\s'\"]*")
|
||||
_DETAIL_SECRET_PREFIXES = ("token ", "Basic ", "Bearer ", "Authorization: ")
|
||||
_SECRET_KEY = (
|
||||
r"token|password|passwd|secret|authorization|api[_-]?key|"
|
||||
r"access[_-]?token|refresh[_-]?token|session|cookie"
|
||||
)
|
||||
_SECRET_JSON_RE = re.compile(
|
||||
r'"(' + _SECRET_KEY + r')"\s*:\s*"[^"]*"', re.IGNORECASE
|
||||
)
|
||||
_SECRET_KV_RE = re.compile(
|
||||
r"\b(" + _SECRET_KEY + r")\s*=\s*[^\s&\"']+", re.IGNORECASE
|
||||
)
|
||||
|
||||
_MUTATION_STAGE_ATTR = "_gitea_mutation_stage"
|
||||
_SELF_DECLARED_REASON_ATTR = "gitea_reason_code"
|
||||
|
||||
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||
|
||||
|
||||
def _safe_str(exc: BaseException) -> str:
|
||||
"""``str(exc)`` that never raises (a poisoned ``__str__`` must not escape)."""
|
||||
try:
|
||||
return str(exc)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_exception_class(exc: BaseException) -> str:
|
||||
"""Fully-qualified type identifier for *exc* — never instance/message text."""
|
||||
try:
|
||||
t = type(exc)
|
||||
mod = getattr(t, "__module__", "") or ""
|
||||
name = getattr(t, "__qualname__", None) or getattr(t, "__name__", "") or ""
|
||||
ident = f"{mod}.{name}" if mod else name
|
||||
return ident[:_CLASS_LIMIT]
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _redact_detail(text: Any, limit: int = _DETAIL_LIMIT) -> str:
|
||||
"""Strictly redact free-form exception text for safe diagnostics.
|
||||
|
||||
Removes token/Authorization credentials, raw URLs and hostnames (via the
|
||||
shared audit redactor), and absolute filesystem paths, then collapses
|
||||
whitespace and truncates. Never raises; on any failure returns "".
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
out = str(text)
|
||||
# Drop credential-prefixed runs (token/Basic/Bearer/Authorization).
|
||||
for prefix in _DETAIL_SECRET_PREFIXES:
|
||||
idx = 0
|
||||
while True:
|
||||
i = out.find(prefix, idx)
|
||||
if i == -1:
|
||||
break
|
||||
j = i + len(prefix)
|
||||
while j < len(out) and not out[j].isspace():
|
||||
j += 1
|
||||
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||||
idx = i + len(prefix) + len("[REDACTED]")
|
||||
# Secret VALUES carried in JSON ("key":"value") or kv (key=value) form,
|
||||
# even short ones (e.g. a password), keyed by a sensitive field name.
|
||||
out = _SECRET_JSON_RE.sub(r'"\1":"[REDACTED]"', out)
|
||||
out = _SECRET_KV_RE.sub(r"\1=[REDACTED]", out)
|
||||
# URLs / query secrets / hostnames.
|
||||
try:
|
||||
import gitea_audit
|
||||
|
||||
out = gitea_audit.redact_urls(out)
|
||||
except Exception:
|
||||
pass
|
||||
# Absolute filesystem paths (workspace layout is not for LLM output).
|
||||
out = _ABS_PATH_RE.sub("[PATH]", out)
|
||||
# Any bare hostname the URL redactor missed (defence in depth).
|
||||
out = re.sub(r"\b[\w.-]+\.(?:cc|net|com|org|io|dev|local)\b", "[HOST]", out)
|
||||
out = " ".join(out.split())
|
||||
return out[:limit]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_mutation_stage(exc: BaseException) -> str | None:
|
||||
"""Return the bounded, redacted mutation stage tagged on *exc* (or None)."""
|
||||
try:
|
||||
raw = getattr(exc, _MUTATION_STAGE_ATTR, None)
|
||||
if not raw:
|
||||
return None
|
||||
return _redact_detail(raw, limit=_STAGE_LIMIT) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fixed_message(reason_code: str) -> str:
|
||||
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||
@@ -154,9 +275,34 @@ def classify_exception(exc: BaseException) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _self_declared_classification(exc: BaseException) -> dict[str, Any] | None:
|
||||
"""Honor a safe ``gitea_reason_code`` attribute set by server-side raisers.
|
||||
|
||||
Converts a raw exception that self-declares a **known** reason code into a
|
||||
typed structured error (fixed message) instead of an opaque internal_error.
|
||||
Unknown / non-string / secret values are ignored (fail closed to internal).
|
||||
"""
|
||||
reason = getattr(exc, _SELF_DECLARED_REASON_ATTR, None)
|
||||
if not isinstance(reason, str) or reason not in FIXED_MESSAGES:
|
||||
return None
|
||||
if reason == REASON_INTERNAL_ERROR:
|
||||
return None
|
||||
return {
|
||||
"reason_code": reason,
|
||||
"error_class": _REASON_ERROR_CLASS.get(reason, ERROR_CLASS_INTERNAL),
|
||||
"http_status": None,
|
||||
"message": fixed_message(reason),
|
||||
"transport_survives": True,
|
||||
}
|
||||
|
||||
|
||||
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||
import gitea_auth
|
||||
|
||||
declared = _self_declared_classification(exc)
|
||||
if declared is not None:
|
||||
return declared
|
||||
|
||||
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||
if code not in (
|
||||
@@ -233,13 +379,23 @@ def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||
return _classify_exception_impl(cause)
|
||||
|
||||
# No message-substring authentication heuristics (reviewer finding #3).
|
||||
return {
|
||||
# Unexpected failure → internal_error, now carrying SAFE diagnostics so the
|
||||
# failure is actionable: a type identifier + strictly-redacted detail +
|
||||
# optional mutation-stage tag. The operator ``message`` stays the fixed
|
||||
# constant; none of these fields carry secrets/bodies/paths/env.
|
||||
result = {
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"http_status": None,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
"exception_class": _safe_exception_class(exc),
|
||||
"detail": _redact_detail(_safe_str(exc)),
|
||||
}
|
||||
stage = _safe_mutation_stage(exc)
|
||||
if stage:
|
||||
result["mutation_stage"] = stage
|
||||
return result
|
||||
|
||||
|
||||
def build_structured_error_payload(
|
||||
@@ -277,6 +433,19 @@ def build_structured_error_payload(
|
||||
payload["tool"] = tool_name[:120]
|
||||
if profile_name and isinstance(profile_name, str):
|
||||
payload["profile"] = profile_name[:80]
|
||||
# Safe diagnostics — internal_error path only. These make an otherwise
|
||||
# opaque crash actionable without leaking secrets. Re-derived here from
|
||||
# the fixed message gate above; typed reasons never carry them.
|
||||
if payload["reason_code"] == REASON_INTERNAL_ERROR:
|
||||
exc_cls = classification.get("exception_class")
|
||||
if isinstance(exc_cls, str) and exc_cls:
|
||||
payload["exception_class"] = exc_cls[:_CLASS_LIMIT]
|
||||
detail = classification.get("detail")
|
||||
if isinstance(detail, str):
|
||||
payload["detail"] = _redact_detail(detail)
|
||||
stage = classification.get("mutation_stage")
|
||||
if isinstance(stage, str) and stage:
|
||||
payload["mutation_stage"] = stage[:_STAGE_LIMIT]
|
||||
return payload
|
||||
except Exception:
|
||||
return {
|
||||
@@ -316,7 +485,21 @@ def log_sanitized_daemon_reason(
|
||||
status = classification.get("http_status")
|
||||
if isinstance(status, int):
|
||||
parts.append(f"http_status={status}")
|
||||
# Intentionally no detail= / message= field — secrets lived there.
|
||||
# Safe diagnostics — internal_error path ONLY. Typed reasons still emit
|
||||
# no detail= (secrets lived there). class/detail/stage are re-redacted
|
||||
# here as defence in depth.
|
||||
if reason == REASON_INTERNAL_ERROR:
|
||||
exc_cls = classification.get("exception_class")
|
||||
if isinstance(exc_cls, str) and exc_cls:
|
||||
parts.append(f"exception_class={exc_cls[:_CLASS_LIMIT]}")
|
||||
stage = classification.get("mutation_stage")
|
||||
if isinstance(stage, str) and stage:
|
||||
parts.append(
|
||||
f"mutation_stage={_redact_detail(stage, limit=_STAGE_LIMIT)}"
|
||||
)
|
||||
detail = classification.get("detail")
|
||||
if isinstance(detail, str) and detail:
|
||||
parts.append(f"detail={_redact_detail(detail)}")
|
||||
line = " ".join(parts)
|
||||
stream.write(line + "\n")
|
||||
if hasattr(stream, "flush"):
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Documented-vs-registered MCP tool inventory guard (#781).
|
||||
|
||||
The workflow documentation named a ``gitea_edit_issue`` tool that no namespace
|
||||
had ever registered. Nothing compared the two lists, so an actor could plan a
|
||||
mutation against a tool that did not exist and only discover it at execution
|
||||
time — after the work was already scoped around it.
|
||||
|
||||
This module is that comparison, in two directions:
|
||||
|
||||
- :func:`assess_inventory_drift` compares the canonical inventory documented in
|
||||
``docs/mcp-tool-inventory.md`` against the tools actually registered on the
|
||||
MCP server. Either list drifting fails the guard, so a new tool must be
|
||||
documented and a removed tool must be undocumented in the same change.
|
||||
- :func:`assess_doc_references` catches the original defect directly: any tool
|
||||
name a workflow/skill document tells an actor to call must be registered.
|
||||
|
||||
Module and script names legitimately appear in the same prose (``gitea_auth``,
|
||||
``offline_mcp_runner``), so :data:`NON_TOOL_IDENTIFIERS` names the known
|
||||
non-tool identifiers explicitly rather than loosening the pattern.
|
||||
|
||||
This module performs no I/O — callers own reading the files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
#: Canonical documented inventory, relative to the repository root.
|
||||
INVENTORY_DOC_PATH = "docs/mcp-tool-inventory.md"
|
||||
|
||||
#: Delimiters around the generated inventory list in the doc.
|
||||
INVENTORY_BEGIN_MARKER = "<!-- BEGIN REGISTERED TOOL INVENTORY -->"
|
||||
INVENTORY_END_MARKER = "<!-- END REGISTERED TOOL INVENTORY -->"
|
||||
|
||||
#: Backticked identifiers that look like tool names but are modules/scripts.
|
||||
#: Every entry is a real file in this repository, not an MCP tool.
|
||||
NON_TOOL_IDENTIFIERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"gitea_auth",
|
||||
"gitea_config",
|
||||
"gitea_mcp_server",
|
||||
"mcp_server",
|
||||
"offline_mcp_helper",
|
||||
"offline_mcp_runner",
|
||||
}
|
||||
)
|
||||
|
||||
#: Prefixes that mark an identifier as a candidate MCP tool name.
|
||||
TOOL_NAME_PREFIXES: tuple[str, ...] = ("gitea_", "mcp_")
|
||||
|
||||
_INVENTORY_ENTRY = re.compile(r"^-\s+`([A-Za-z_][A-Za-z0-9_]*)`")
|
||||
_BACKTICKED = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
|
||||
|
||||
|
||||
def parse_documented_inventory(text: str) -> list[str]:
|
||||
"""Return the tool names listed between the inventory markers.
|
||||
|
||||
Raises ``ValueError`` when the markers are missing or out of order, so a
|
||||
mangled document fails the guard instead of silently documenting nothing.
|
||||
"""
|
||||
start = text.find(INVENTORY_BEGIN_MARKER)
|
||||
end = text.find(INVENTORY_END_MARKER)
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise ValueError(
|
||||
f"{INVENTORY_DOC_PATH} must contain "
|
||||
f"'{INVENTORY_BEGIN_MARKER}' followed by "
|
||||
f"'{INVENTORY_END_MARKER}' (fail closed)."
|
||||
)
|
||||
block = text[start + len(INVENTORY_BEGIN_MARKER) : end]
|
||||
names: list[str] = []
|
||||
for line in block.splitlines():
|
||||
match = _INVENTORY_ENTRY.match(line.strip())
|
||||
if match:
|
||||
names.append(match.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def looks_like_tool_name(identifier: str) -> bool:
|
||||
"""Return whether a backticked identifier is a candidate tool name."""
|
||||
if identifier in NON_TOOL_IDENTIFIERS:
|
||||
return False
|
||||
return identifier.startswith(TOOL_NAME_PREFIXES)
|
||||
|
||||
|
||||
def extract_tool_references(text: str) -> set[str]:
|
||||
"""Return candidate tool names a document tells an actor to call."""
|
||||
return {
|
||||
name
|
||||
for name in _BACKTICKED.findall(text)
|
||||
if looks_like_tool_name(name)
|
||||
}
|
||||
|
||||
|
||||
def assess_inventory_drift(
|
||||
documented: Iterable[str],
|
||||
registered: Iterable[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Compare the documented inventory against the registered tool set."""
|
||||
documented_list = list(documented)
|
||||
documented_set = set(documented_list)
|
||||
registered_set = set(registered)
|
||||
|
||||
duplicates = sorted(
|
||||
{name for name in documented_list if documented_list.count(name) > 1}
|
||||
)
|
||||
documented_not_registered = sorted(documented_set - registered_set)
|
||||
registered_not_documented = sorted(registered_set - documented_set)
|
||||
unsorted = documented_list != sorted(documented_list)
|
||||
|
||||
reasons: list[str] = []
|
||||
if documented_not_registered:
|
||||
reasons.append(
|
||||
"documented but not registered: "
|
||||
+ ", ".join(documented_not_registered)
|
||||
)
|
||||
if registered_not_documented:
|
||||
reasons.append(
|
||||
"registered but not documented: "
|
||||
+ ", ".join(registered_not_documented)
|
||||
)
|
||||
if duplicates:
|
||||
reasons.append("listed more than once: " + ", ".join(duplicates))
|
||||
if unsorted:
|
||||
reasons.append("inventory entries are not in sorted order")
|
||||
|
||||
in_sync = not reasons
|
||||
return {
|
||||
"in_sync": in_sync,
|
||||
"documented_count": len(documented_set),
|
||||
"registered_count": len(registered_set),
|
||||
"documented_not_registered": documented_not_registered,
|
||||
"registered_not_documented": registered_not_documented,
|
||||
"duplicates": duplicates,
|
||||
"sorted": not unsorted,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if in_sync
|
||||
else (
|
||||
f"Update {INVENTORY_DOC_PATH} so the block between the "
|
||||
"inventory markers lists exactly the registered tools, sorted, "
|
||||
"one '- `tool_name`' entry per line."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_doc_references(
|
||||
references: Mapping[str, Iterable[str]],
|
||||
registered: Iterable[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Verify every tool a document names is actually registered.
|
||||
|
||||
*references* maps a document path to the candidate tool names it mentions.
|
||||
"""
|
||||
registered_set = set(registered)
|
||||
unregistered: list[dict[str, Any]] = []
|
||||
checked = 0
|
||||
for path, names in sorted(references.items()):
|
||||
for name in sorted(set(names)):
|
||||
checked += 1
|
||||
if name not in registered_set:
|
||||
unregistered.append({"document": path, "tool": name})
|
||||
|
||||
clean = not unregistered
|
||||
reasons = [
|
||||
f"{entry['document']} documents '{entry['tool']}', "
|
||||
"which no namespace registers"
|
||||
for entry in unregistered
|
||||
]
|
||||
return {
|
||||
"clean": clean,
|
||||
"checked_count": checked,
|
||||
"unregistered": unregistered,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Either register the named tool with @mcp.tool() or correct the "
|
||||
"document. Documentation must never name a tool an actor cannot "
|
||||
"reach. If the identifier is a module or script rather than a "
|
||||
"tool, add it to mcp_tool_inventory.NON_TOOL_IDENTIFIERS."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_inventory_block(registered: Iterable[str]) -> str:
|
||||
"""Render the marker-delimited inventory block for the documentation."""
|
||||
lines = [INVENTORY_BEGIN_MARKER, ""]
|
||||
lines.extend(f"- `{name}`" for name in sorted(set(registered)))
|
||||
lines.extend(["", INVENTORY_END_MARKER])
|
||||
return "\n".join(lines)
|
||||
@@ -18,16 +18,34 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
SOURCE_RELEASE_MERGER = "gitea_release_merger_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_ACQUIRE_MERGER,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||
|
||||
# #742: owner-session terminal finalization of a merger-held lease. Append-only
|
||||
# marker; ledger history is never edited or deleted.
|
||||
MERGER_FINALIZATION_MARKER = "<!-- mcp-merger-lease-final:v1 -->"
|
||||
OUTCOME_RELEASED = "released"
|
||||
OUTCOME_ABANDONED = "abandoned"
|
||||
MERGER_FINALIZATION_OUTCOMES = frozenset({OUTCOME_RELEASED, OUTCOME_ABANDONED})
|
||||
DEFAULT_MERGER_FINALIZATION_REASON = "merge-not-performed"
|
||||
|
||||
# Provenance sources whose in-session lease is merger-owned and therefore
|
||||
# finalizable by its owning merger session.
|
||||
MERGER_OWNED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ACQUIRE_MERGER,
|
||||
SOURCE_ADOPT,
|
||||
})
|
||||
|
||||
|
||||
def format_adoption_body(
|
||||
*,
|
||||
@@ -95,6 +113,7 @@ def build_lease_provenance(
|
||||
adopted_from_profile: str | None = None,
|
||||
adopted_from_reviewer_identity: str | None = None,
|
||||
adoption_reason: str | None = None,
|
||||
native_token_fingerprint: str | None = None,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||
@@ -115,9 +134,76 @@ def build_lease_provenance(
|
||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||
if adoption_reason:
|
||||
proof["adoption_reason"] = adoption_reason
|
||||
if native_token_fingerprint:
|
||||
proof["native_token_fingerprint"] = native_token_fingerprint
|
||||
return proof
|
||||
|
||||
|
||||
def assess_acquired_merger_lease_integrity(
|
||||
session: dict[str, Any] | None,
|
||||
) -> list[str]:
|
||||
"""Ownership/integrity reasons blocking a ``SOURCE_ACQUIRE_MERGER`` lease (#742).
|
||||
|
||||
A merger lease minted by ``gitea_acquire_merger_pr_lease`` authorizes an
|
||||
irreversible merge, so it is sanctioned only when the in-session record is
|
||||
complete and self-consistent: comment marker, exact session identity, merger
|
||||
profile/role, repository, PR number, and pinned candidate head. Any missing
|
||||
or contradictory field fails closed — an incomplete record is not proof.
|
||||
"""
|
||||
if not session:
|
||||
return ["no in-session lease recorded"]
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
reasons: list[str] = []
|
||||
|
||||
provenance_comment_id = provenance.get("comment_id")
|
||||
session_comment_id = session.get("comment_id")
|
||||
if not (provenance_comment_id or session_comment_id):
|
||||
reasons.append(
|
||||
"acquired merger lease has no comment marker id (comment-backed "
|
||||
"proof required)"
|
||||
)
|
||||
elif (
|
||||
provenance_comment_id is not None
|
||||
and session_comment_id is not None
|
||||
and provenance_comment_id != session_comment_id
|
||||
):
|
||||
reasons.append(
|
||||
"acquired merger lease provenance comment_id does not match the "
|
||||
"session lease comment_id"
|
||||
)
|
||||
|
||||
if not (session.get("session_id") or "").strip():
|
||||
reasons.append("acquired merger lease has no session_id")
|
||||
if not (session.get("reviewer_identity") or "").strip():
|
||||
reasons.append("acquired merger lease has no holder identity")
|
||||
|
||||
profile = (session.get("profile") or "").strip()
|
||||
if not profile:
|
||||
reasons.append("acquired merger lease has no profile")
|
||||
elif "merger" not in profile.lower():
|
||||
reasons.append(
|
||||
f"acquired merger lease profile '{profile}' is not a merger profile "
|
||||
"(merger-only; fail closed)"
|
||||
)
|
||||
|
||||
if not (session.get("repo") or "").strip():
|
||||
reasons.append("acquired merger lease has no repository")
|
||||
|
||||
pr_number = session.get("pr_number")
|
||||
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number <= 0:
|
||||
reasons.append("acquired merger lease has no valid PR number")
|
||||
|
||||
if not leases._normalize_sha(session.get("candidate_head")):
|
||||
reasons.append(
|
||||
"acquired merger lease has no pinned candidate_head (exact-head "
|
||||
"scoping required)"
|
||||
)
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
if not session:
|
||||
return False
|
||||
@@ -129,6 +215,8 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
return bool(provenance.get("comment_id")) and bool(
|
||||
provenance.get("adopted_from_session_id")
|
||||
)
|
||||
if source == SOURCE_ACQUIRE_MERGER:
|
||||
return not assess_acquired_merger_lease_integrity(session)
|
||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||
return False
|
||||
@@ -166,6 +254,8 @@ def describe_session_lease_proof(
|
||||
if source == SOURCE_ADOPT and sanctioned:
|
||||
kind = "sanctioned_adoption"
|
||||
reason = reason or DEFAULT_ADOPTION_REASON
|
||||
elif source == SOURCE_ACQUIRE_MERGER and sanctioned:
|
||||
kind = "sanctioned_acquire_merger"
|
||||
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||
kind = "sanctioned_acquire"
|
||||
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||
@@ -209,9 +299,12 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||
r"(?is)\b("
|
||||
r"gitea_adopt_merger_pr_lease|"
|
||||
r"gitea_acquire_reviewer_pr_lease|"
|
||||
r"gitea_acquire_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire_merger|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||
r"adoption_comment_id\s*[:=]|"
|
||||
r"sanctioned_adoption|"
|
||||
@@ -243,6 +336,267 @@ def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def format_merger_finalization_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
merger_identity: str,
|
||||
merger_profile: str,
|
||||
merger_session_id: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
outcome: str,
|
||||
reason: str,
|
||||
lease_comment_id: int | None,
|
||||
finalized_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Render the append-only terminal marker for a merger-owned lease (#742).
|
||||
|
||||
The body carries a standard lease marker in a terminal phase, so the
|
||||
existing newest-wins ledger (#577) ends the lease without editing or
|
||||
deleting any prior comment.
|
||||
"""
|
||||
finalized_at = finalized_at or datetime.now(timezone.utc)
|
||||
finalized_text = finalized_at.astimezone(timezone.utc).replace(
|
||||
microsecond=0
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
lease_body = leases.format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=merger_identity,
|
||||
profile=merger_profile,
|
||||
session_id=merger_session_id,
|
||||
worktree=worktree,
|
||||
phase=outcome,
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=finalized_at,
|
||||
blocker=reason,
|
||||
)
|
||||
lines = [
|
||||
MERGER_FINALIZATION_MARKER,
|
||||
f"finalized_at: {finalized_text}",
|
||||
f"finalized_by_identity: {merger_identity}",
|
||||
f"finalized_by_profile: {merger_profile}",
|
||||
f"finalized_by_session_id: {merger_session_id}",
|
||||
f"finalization_outcome: {outcome}",
|
||||
f"finalization_reason: {reason}",
|
||||
f"finalized_lease_comment_id: {lease_comment_id or 'none'}",
|
||||
lease_body,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_merger_finalization_comment(body: str) -> bool:
|
||||
return MERGER_FINALIZATION_MARKER in (body or "")
|
||||
|
||||
|
||||
def assess_merger_lease_finalization(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
session: dict[str, Any] | None,
|
||||
actor_identity: str,
|
||||
actor_profile: str,
|
||||
actor_session_id: str | None,
|
||||
repo: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
live_head_sha: str | None = None,
|
||||
outcome: str = OUTCOME_RELEASED,
|
||||
reason: str | None = None,
|
||||
runtime_token_fingerprint: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
target_branch: str = "master",
|
||||
target_branch_sha: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merger may terminally finalize its own lease (#742).
|
||||
|
||||
Owner-session only: the caller must hold the exact comment-backed merger
|
||||
lease it is finalizing. Foreign sessions, reviewer profiles, and mismatched
|
||||
repository/PR/head/token-fingerprint callers fail closed. Already-terminal
|
||||
leases return ``already_terminal`` so repeat calls are idempotent and post
|
||||
no second marker.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
outcome = (outcome or "").strip().lower()
|
||||
finalization_reason = (reason or "").strip() or DEFAULT_MERGER_FINALIZATION_REASON
|
||||
|
||||
if outcome not in MERGER_FINALIZATION_OUTCOMES:
|
||||
reasons.append(
|
||||
f"outcome '{outcome or 'none'}' is not a terminal merger "
|
||||
f"finalization outcome ({sorted(MERGER_FINALIZATION_OUTCOMES)})"
|
||||
)
|
||||
|
||||
if "merger" not in (actor_profile or "").lower():
|
||||
reasons.append(
|
||||
f"profile '{actor_profile or 'unknown'}' is not a merger profile; "
|
||||
"merger lease finalization is merger-only (fail closed). Reviewer "
|
||||
"sessions use gitea_release_reviewer_pr_lease."
|
||||
)
|
||||
|
||||
session = session or None
|
||||
provenance = (session or {}).get("lease_provenance") or {}
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
source = (provenance.get("source") or "").strip()
|
||||
|
||||
if not session:
|
||||
reasons.append(
|
||||
"no in-session merger lease recorded; only the owning session may "
|
||||
"finalize a merger lease"
|
||||
)
|
||||
elif source not in MERGER_OWNED_PROVENANCE_SOURCES:
|
||||
reasons.append(
|
||||
f"in-session lease provenance '{source or 'none'}' is not a "
|
||||
"merger-owned lease; refusing to finalize"
|
||||
)
|
||||
elif not is_sanctioned_session_lease(session):
|
||||
reasons.extend(
|
||||
assess_acquired_merger_lease_integrity(session)
|
||||
if source == SOURCE_ACQUIRE_MERGER
|
||||
else ["in-session merger lease lacks sanctioned provenance"]
|
||||
)
|
||||
|
||||
pinned = leases._normalize_sha(candidate_head)
|
||||
live = leases._normalize_sha(live_head_sha)
|
||||
if not pinned:
|
||||
reasons.append(
|
||||
"candidate_head is required for merger lease finalization "
|
||||
"(exact-head scoping; fail closed)"
|
||||
)
|
||||
if live and pinned and live != pinned:
|
||||
reasons.append(
|
||||
"candidate_head does not match live PR head (fail closed)"
|
||||
)
|
||||
|
||||
if session:
|
||||
session_sid = (session.get("session_id") or "").strip()
|
||||
actor_sid = (actor_session_id or "").strip()
|
||||
if not actor_sid or session_sid != actor_sid:
|
||||
reasons.append(
|
||||
"session_id does not match the in-session merger lease owner; "
|
||||
"foreign-session release is not permitted (fail closed)"
|
||||
)
|
||||
if session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"in-session merger lease is for PR #{session.get('pr_number')}, "
|
||||
f"not #{pr_number}"
|
||||
)
|
||||
session_repo = (session.get("repo") or "").strip()
|
||||
if session_repo and session_repo != (repo or "").strip():
|
||||
reasons.append(
|
||||
f"in-session merger lease repository '{session_repo}' does not "
|
||||
f"match '{repo}' (fail closed)"
|
||||
)
|
||||
session_head = leases._normalize_sha(session.get("candidate_head"))
|
||||
if pinned and session_head and session_head != pinned:
|
||||
reasons.append(
|
||||
"in-session merger lease candidate_head does not match the "
|
||||
"supplied candidate_head (fail closed)"
|
||||
)
|
||||
session_identity = (session.get("reviewer_identity") or "").strip()
|
||||
if session_identity and session_identity != (actor_identity or "").strip():
|
||||
reasons.append(
|
||||
"authenticated identity does not match the in-session merger "
|
||||
"lease holder (fail closed)"
|
||||
)
|
||||
session_profile = (session.get("profile") or "").strip()
|
||||
if session_profile and session_profile != (actor_profile or "").strip():
|
||||
reasons.append(
|
||||
"active profile does not match the in-session merger lease "
|
||||
"profile (fail closed)"
|
||||
)
|
||||
recorded_fingerprint = (
|
||||
provenance.get("native_token_fingerprint") or ""
|
||||
).strip()
|
||||
live_fingerprint = (runtime_token_fingerprint or "").strip()
|
||||
if (
|
||||
recorded_fingerprint
|
||||
and live_fingerprint
|
||||
and recorded_fingerprint != live_fingerprint
|
||||
):
|
||||
reasons.append(
|
||||
"native runtime token fingerprint does not match the one "
|
||||
"recorded when the merger lease was acquired (fail closed)"
|
||||
)
|
||||
|
||||
lease_comment_id = (session or {}).get("comment_id") or provenance.get("comment_id")
|
||||
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||
if session and lease_comment_id is not None:
|
||||
if not any(entry.get("comment_id") == lease_comment_id for entry in entries):
|
||||
reasons.append(
|
||||
f"lease marker comment {lease_comment_id} is not present on PR "
|
||||
f"#{pr_number} (fail closed)"
|
||||
)
|
||||
|
||||
active = leases.find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
already_terminal = False
|
||||
if active:
|
||||
owner_session = (active.get("session_id") or "").strip()
|
||||
if owner_session and owner_session != (actor_session_id or "").strip():
|
||||
reasons.append(
|
||||
f"active PR lease is owned by session_id={owner_session}; a "
|
||||
"merger may only finalize its own lease (fail closed)"
|
||||
)
|
||||
else:
|
||||
newest = entries[-1] if entries else None
|
||||
newest_phase = ((newest or {}).get("phase") or "").strip().lower()
|
||||
if newest and newest_phase in leases._TERMINAL_PHASES:
|
||||
already_terminal = True
|
||||
elif not entries:
|
||||
reasons.append(
|
||||
f"no comment-backed lease marker found on PR #{pr_number}"
|
||||
)
|
||||
|
||||
finalize_allowed = not reasons and not already_terminal
|
||||
body = None
|
||||
if finalize_allowed and session:
|
||||
body = format_merger_finalization_body(
|
||||
repo=(session.get("repo") or repo),
|
||||
pr_number=pr_number,
|
||||
issue_number=(
|
||||
issue_number
|
||||
if issue_number is not None
|
||||
else session.get("issue_number")
|
||||
),
|
||||
merger_identity=actor_identity,
|
||||
merger_profile=actor_profile,
|
||||
merger_session_id=(actor_session_id or ""),
|
||||
worktree=worktree or (session.get("worktree") or ""),
|
||||
candidate_head=pinned,
|
||||
target_branch=(
|
||||
session.get("target_branch") or target_branch or "master"
|
||||
),
|
||||
target_branch_sha=(
|
||||
session.get("target_branch_sha") or target_branch_sha
|
||||
),
|
||||
outcome=outcome,
|
||||
reason=finalization_reason,
|
||||
lease_comment_id=lease_comment_id,
|
||||
finalized_at=now,
|
||||
)
|
||||
|
||||
return {
|
||||
"finalize_allowed": finalize_allowed,
|
||||
"already_terminal": already_terminal,
|
||||
"reasons": reasons,
|
||||
"outcome": outcome,
|
||||
"finalization_reason": finalization_reason,
|
||||
"active_lease": active,
|
||||
"finalization_body": body,
|
||||
"lease_comment_id": lease_comment_id,
|
||||
"candidate_head": pinned,
|
||||
}
|
||||
|
||||
|
||||
def assess_adopt_merger_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Mutation-budget classification for auto-mode attempts (#617).
|
||||
|
||||
Mutation budget must count only *server-side* Gitea state changes. A tool call
|
||||
that fails closed before the Gitea API is reached changed nothing on the
|
||||
server, so it must not consume the budget that protects against repeated real
|
||||
mutations.
|
||||
|
||||
The classifier separates four outcome classes plus an explicit ambiguous class:
|
||||
|
||||
``local_validator_rejection``
|
||||
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
|
||||
``## Canonical Issue State`` blocks) rejected the payload before any API
|
||||
call. No server-side state exists.
|
||||
|
||||
``capability_gate_rejection``
|
||||
A profile/permission gate refused the operation before any API call.
|
||||
|
||||
``transport_failure_before_api``
|
||||
The request never reached the Gitea API (transport/EOF/connection error).
|
||||
|
||||
``server_side_mutation``
|
||||
The API succeeded and returned proof of durable state (comment id, review
|
||||
id, merge commit, label result, or an issue/PR state change).
|
||||
|
||||
``ambiguous_requires_readback``
|
||||
The API *was* reached but the result carries no usable proof either way.
|
||||
This fails closed: the attempt is treated as budget-consuming until a
|
||||
read-after-write check proves otherwise, so #617 never weakens the guard
|
||||
that prevents repeated real mutations.
|
||||
|
||||
Only ``server_side_mutation`` consumes budget outright. Every attempt — failed
|
||||
or not — is still recorded in the local attempt ledger so a final report can
|
||||
show local failed attempts, blocked API attempts, and successful server-side
|
||||
mutations separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
|
||||
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
|
||||
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
|
||||
SERVER_SIDE_MUTATION = "server_side_mutation"
|
||||
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
|
||||
|
||||
CLASSIFICATIONS = (
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
SERVER_SIDE_MUTATION,
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
)
|
||||
|
||||
#: Result fields that prove durable server-side state was created.
|
||||
MUTATION_PROOF_FIELDS = (
|
||||
"comment_id",
|
||||
"review_id",
|
||||
"merge_commit_sha",
|
||||
"label_result",
|
||||
"state_change",
|
||||
"created_pr_number",
|
||||
)
|
||||
|
||||
#: Classes that never consume server-side mutation budget.
|
||||
PRE_API_CLASSIFICATIONS = (
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
)
|
||||
|
||||
FINAL_REPORT_REQUIRED_FIELDS = (
|
||||
"local_failed_attempts",
|
||||
"blocked_api_attempts",
|
||||
"successful_server_mutations",
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
|
||||
|
||||
|
||||
def _proof_fields_present(result: dict) -> list[str]:
|
||||
"""Return the mutation-proof fields carrying a usable value."""
|
||||
present: list[str] = []
|
||||
for field in MUTATION_PROOF_FIELDS:
|
||||
value = result.get(field)
|
||||
if value is None or value is False:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
present.append(field)
|
||||
return present
|
||||
|
||||
|
||||
def _decision(
|
||||
classification: str,
|
||||
*,
|
||||
budget_consumed: bool,
|
||||
requires_readback: bool,
|
||||
reasons: list[str],
|
||||
proof_fields: list[str],
|
||||
api_called: bool | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"classification": classification,
|
||||
"budget_consumed": budget_consumed,
|
||||
"requires_readback": requires_readback,
|
||||
"pre_api": classification in PRE_API_CLASSIFICATIONS,
|
||||
"api_called": api_called,
|
||||
"proof_fields": proof_fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def classify_mutation_attempt(result: dict | None) -> dict:
|
||||
"""Classify one mutation attempt and decide whether it consumes budget.
|
||||
|
||||
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
|
||||
pre-interpret it: classification is driven by the explicit ``api_called``
|
||||
signal plus the proof fields the tool reports.
|
||||
"""
|
||||
data = dict(result or {})
|
||||
success = bool(data.get("success"))
|
||||
proof_fields = _proof_fields_present(data)
|
||||
api_called = data.get("api_called")
|
||||
|
||||
# An unambiguous success carrying durable proof is a real mutation however
|
||||
# the attempt was labelled upstream.
|
||||
if success and proof_fields:
|
||||
return _decision(
|
||||
SERVER_SIDE_MUTATION,
|
||||
budget_consumed=True,
|
||||
requires_readback=False,
|
||||
reasons=[
|
||||
"API reported success with durable proof field(s): "
|
||||
+ ", ".join(proof_fields)
|
||||
],
|
||||
proof_fields=proof_fields,
|
||||
api_called=True,
|
||||
)
|
||||
|
||||
if api_called is False:
|
||||
# Nothing reached the server; pick the precise pre-API class.
|
||||
if data.get("transport_error") or data.get("transport_failed"):
|
||||
return _decision(
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=["transport failed before the Gitea API was reached"],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
if data.get("permission_report") or data.get("capability_blocked"):
|
||||
return _decision(
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=["capability/permission gate refused before any API call"],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
return _decision(
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=[
|
||||
"local validator rejected the payload before any API call; "
|
||||
"no server-side state was created"
|
||||
],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
|
||||
if api_called is True:
|
||||
if success:
|
||||
reason = (
|
||||
"API reported success but returned no durable proof field; "
|
||||
"read-after-write verification required before counting budget"
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"API was reached and the outcome carries no durable proof; "
|
||||
"read-after-write verification required before counting budget"
|
||||
)
|
||||
return _decision(
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
budget_consumed=True,
|
||||
requires_readback=True,
|
||||
reasons=[reason],
|
||||
proof_fields=proof_fields,
|
||||
api_called=True,
|
||||
)
|
||||
|
||||
# ``api_called`` was not reported at all. Fail closed rather than assuming
|
||||
# nothing happened.
|
||||
return _decision(
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
budget_consumed=True,
|
||||
requires_readback=True,
|
||||
reasons=[
|
||||
"attempt did not report 'api_called'; cannot prove the request "
|
||||
"stopped before the Gitea API, so the attempt fails closed"
|
||||
],
|
||||
proof_fields=proof_fields,
|
||||
api_called=None,
|
||||
)
|
||||
|
||||
|
||||
def record_attempt(
|
||||
ledger: list[dict] | None,
|
||||
result: dict | None,
|
||||
*,
|
||||
operation: str = "",
|
||||
timestamp: str | None = None,
|
||||
) -> dict:
|
||||
"""Append one classified attempt to the local ledger and return the entry.
|
||||
|
||||
Every attempt is recorded, including the ones that consume no budget: the
|
||||
point of #617 is that failed local attempts stay visible without being
|
||||
miscounted as Gitea mutations.
|
||||
"""
|
||||
entries = ledger if isinstance(ledger, list) else []
|
||||
entry = {
|
||||
"operation": _clean(operation),
|
||||
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
|
||||
**classify_mutation_attempt(result),
|
||||
}
|
||||
entries.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
|
||||
"""Summarize a ledger into the categories a final report must show."""
|
||||
entries = [e for e in (ledger or []) if isinstance(e, dict)]
|
||||
|
||||
def _count(*classifications: str) -> int:
|
||||
return sum(1 for e in entries if e.get("classification") in classifications)
|
||||
|
||||
return {
|
||||
"total_attempts": len(entries),
|
||||
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
|
||||
"blocked_api_attempts": _count(
|
||||
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
|
||||
),
|
||||
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
|
||||
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
|
||||
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
|
||||
"requires_readback": any(e.get("requires_readback") for e in entries),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def assess_final_report_mutation_accounting(
|
||||
report: dict | None,
|
||||
ledger: list[dict] | None,
|
||||
) -> dict:
|
||||
"""Fail closed when a report's mutation accounting contradicts the ledger."""
|
||||
data = dict(report or {})
|
||||
summary = summarize_attempt_ledger(ledger)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in FINAL_REPORT_REQUIRED_FIELDS:
|
||||
if field not in data:
|
||||
reasons.append(f"final report omits required field '{field}'")
|
||||
continue
|
||||
claimed = data.get(field)
|
||||
actual = summary[field]
|
||||
if claimed != actual:
|
||||
reasons.append(
|
||||
f"final report claims {field}={claimed} but the attempt ledger "
|
||||
f"shows {actual}"
|
||||
)
|
||||
|
||||
if summary["requires_readback"] and not data.get("readback_verified"):
|
||||
reasons.append(
|
||||
"ledger contains an ambiguous attempt; final report must record "
|
||||
"'readback_verified' proof before claiming mutation accounting"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
|
||||
}
|
||||
+198
-39
@@ -55,24 +55,86 @@ def resolve_namespace_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
durable_author_result: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||
|
||||
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||
can never name a valid task workspace, so resolution falls through to the
|
||||
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||
path must fail loudly downstream rather than silently rebind. Demotion
|
||||
notes are appended to *demotions* when provided.
|
||||
|
||||
Author role (#618): never demotes a missing configured binding to the
|
||||
control checkout. When *verify_paths* is true, resolution goes through
|
||||
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
||||
mutations either use an explicit validated worktree, derive from the
|
||||
active author issue lock, or fail closed. Runtime-context and mutation
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
# #618: durable author resolution — no silent control/master fallback.
|
||||
if role == "author" and verify_paths:
|
||||
durable = durable_author_result
|
||||
if durable is None:
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
# Path selection only here; full validation is re-run in
|
||||
# resolve_namespace_mutation_context with the canonical root.
|
||||
validate=False,
|
||||
)
|
||||
workspace = durable.get("workspace_path") or os.path.realpath(
|
||||
process_project_root
|
||||
)
|
||||
source = durable.get("workspace_binding_source") or "no author worktree binding"
|
||||
if demotions is not None and durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
return workspace, source
|
||||
|
||||
for candidate, source, env_sourced in (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(_env_value(env_map, role_env_key),
|
||||
f"{role_env_key} environment variable", True),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
"reviewer PR lease worktree", False),
|
||||
# Author lock derivation is handled by the durable path above when
|
||||
# verify_paths is true; when verify_paths is false, surface the lock
|
||||
# path as a non-demoted candidate so tooling can inspect it.
|
||||
(session_lock_worktree if role == "author" else None,
|
||||
"active author issue lock worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
if not text:
|
||||
continue
|
||||
real = os.path.realpath(os.path.abspath(text))
|
||||
if verify_paths and env_sourced and not os.path.isdir(real):
|
||||
if demotions is not None:
|
||||
demotions.append(
|
||||
f"{source} '{real}' demoted: path no longer exists "
|
||||
"(stale binding, #702)"
|
||||
)
|
||||
continue
|
||||
return real, source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
@@ -84,21 +146,67 @@ def resolve_namespace_mutation_context(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||
|
||||
When *configured_canonical_root* is supplied (a cross-repository namespace
|
||||
bound to an external target repository, #706), the canonical repository root
|
||||
is that configured target rather than the MCP install checkout. This keeps
|
||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||
repository the namespace actually mutates. Without it the single-repo
|
||||
default is preserved: the canonical root follows the process checkout.
|
||||
|
||||
Author role (#618): uses durable worktree resolution (explicit path, env,
|
||||
or active issue lock) and never silently falls back to the control checkout.
|
||||
"""
|
||||
demotions: list[str] = []
|
||||
env_map = env if env is not None else os.environ
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
|
||||
durable: dict | None = None
|
||||
if role == "author":
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
canonical_repo_root=canonical_root,
|
||||
profile_name=profile_name,
|
||||
validate=True,
|
||||
)
|
||||
workspace = durable["workspace_path"]
|
||||
binding_source = durable["workspace_binding_source"]
|
||||
if durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{binding_source} '{workspace}' not demoted: "
|
||||
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
else:
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
@@ -106,16 +214,26 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
result = {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
if durable is not None:
|
||||
result["author_worktree_resolution"] = durable
|
||||
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
||||
result["path_exists"] = durable.get("path_exists")
|
||||
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
||||
result["inspected_git_root"] = durable.get("inspected_git_root")
|
||||
result["author_worktree_block"] = bool(durable.get("block"))
|
||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||
result["operator_recovery"] = durable.get("operator_recovery")
|
||||
return result
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
@@ -192,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
operator_recovery: str | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
reason_list = list(reasons or [])
|
||||
# #618: prefer the durable author missing-worktree message when present.
|
||||
if role == "author" and any(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
||||
):
|
||||
return amw.format_bound_worktree_missing_error(
|
||||
{
|
||||
"reasons": reason_list,
|
||||
"binding_source": binding_source,
|
||||
"configured_path": workspace_path,
|
||||
"role_kind": role,
|
||||
"operator_recovery": operator_recovery
|
||||
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
)
|
||||
try:
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
except OSError:
|
||||
workspace = workspace_path
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
@@ -210,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
if reason_list:
|
||||
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||
if operator_recovery:
|
||||
parts.append(f"Operator recovery: {operator_recovery}")
|
||||
else:
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -230,8 +370,10 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
@@ -241,7 +383,9 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
configured_canonical_root=configured_canonical_root,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
@@ -264,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
operator_recovery = ctx.get("operator_recovery")
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
# #618 durable resolution already validated existence, membership,
|
||||
# branches/, lock ownership, and traversal safety when present.
|
||||
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||
if durable_reasons:
|
||||
reasons.extend(durable_reasons)
|
||||
elif ctx.get("author_worktree_block"):
|
||||
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
else:
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
@@ -304,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||
"path_exists": ctx.get("path_exists"),
|
||||
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||
"inspected_git_root": ctx.get("inspected_git_root"),
|
||||
"operator_recovery": operator_recovery,
|
||||
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Reconciler authorization gate for post-merge moot-lease cleanup (#745).
|
||||
|
||||
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
|
||||
released`` lease marker — a real, durable mutation of the PR lease ledger.
|
||||
Before #745 it was gated on permissions alone (``gitea.read`` to enter,
|
||||
``gitea.pr.comment`` to apply) with no canonical task and no role binding, so
|
||||
any profile carrying ``gitea.pr.comment`` reached the mutation path while the
|
||||
reconciler could not satisfy the operator-required resolve-exact-task ->
|
||||
mutation sequence.
|
||||
|
||||
This module holds the pure half of that gate:
|
||||
|
||||
* the canonical task name and its tool-name alias;
|
||||
* an **append-only** in-process ledger of read-only dry-run assessments;
|
||||
* ``assess_apply_authorization``, which decides whether an apply may proceed.
|
||||
|
||||
Apply is authorized only when all of the following hold:
|
||||
|
||||
* the session resolved exactly the cleanup task (no other task substitutes);
|
||||
* the active profile role is ``reconciler``;
|
||||
* a prior dry run in this session recorded ``lease_moot`` and
|
||||
``cleanup_allowed`` for the *same* repository, PR, lease session, candidate
|
||||
head and lease marker id;
|
||||
* the live assessment still agrees with that evidence, so a lease superseded
|
||||
between the dry run and the apply fails closed;
|
||||
* any caller-supplied expectations match the live lease exactly.
|
||||
|
||||
Everything else fails closed. The ledger is only ever appended to — a
|
||||
superseded dry run stays visible as history instead of being rewritten — which
|
||||
keeps the cleanup audit trail append-only end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_TASK = "cleanup_post_merge_moot_lease"
|
||||
CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
|
||||
REQUIRED_ROLE = "reconciler"
|
||||
REQUIRED_PERMISSION = "gitea.pr.comment"
|
||||
|
||||
# The read-only assessment stays reachable under gitea.read for every role —
|
||||
# the convention shared with cleanup_stale_review_decision_lock and
|
||||
# cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a
|
||||
# stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE.
|
||||
ASSESSMENT_PERMISSION = "gitea.read"
|
||||
|
||||
_DRY_RUN_LEDGER: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def _norm(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _norm_comment_id(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def record_dry_run(
|
||||
*,
|
||||
pr_number: int,
|
||||
repository_slug: str | None,
|
||||
lease_moot: bool,
|
||||
cleanup_allowed: bool,
|
||||
session_id: str | None,
|
||||
candidate_head: str | None,
|
||||
lease_comment_id: Any,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append one read-only assessment to the dry-run ledger.
|
||||
|
||||
Never rewrites or removes a prior entry: repeated dry runs accumulate and
|
||||
``latest_dry_run`` returns the newest matching one.
|
||||
"""
|
||||
entry = {
|
||||
"task": CLEANUP_TASK,
|
||||
"pr_number": int(pr_number),
|
||||
"repository_slug": _norm(repository_slug) or None,
|
||||
"lease_moot": bool(lease_moot),
|
||||
"cleanup_allowed": bool(cleanup_allowed),
|
||||
"session_id": _norm(session_id) or None,
|
||||
"candidate_head": _norm(candidate_head) or None,
|
||||
"lease_comment_id": _norm_comment_id(lease_comment_id),
|
||||
"recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(),
|
||||
}
|
||||
_DRY_RUN_LEDGER.append(entry)
|
||||
return dict(entry)
|
||||
|
||||
|
||||
def dry_run_history() -> tuple[dict[str, Any], ...]:
|
||||
"""Immutable view of every recorded dry run, oldest first."""
|
||||
return tuple(dict(entry) for entry in _DRY_RUN_LEDGER)
|
||||
|
||||
|
||||
def latest_dry_run(
|
||||
*, pr_number: int, repository_slug: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Newest dry-run evidence for this repository + PR, or None."""
|
||||
wanted_repo = _norm(repository_slug)
|
||||
for entry in reversed(_DRY_RUN_LEDGER):
|
||||
if entry["pr_number"] != int(pr_number):
|
||||
continue
|
||||
if _norm(entry.get("repository_slug")) != wanted_repo:
|
||||
continue
|
||||
return dict(entry)
|
||||
return None
|
||||
|
||||
|
||||
def _reset_for_testing() -> None:
|
||||
"""Drop ledger state between tests. Never called by production paths."""
|
||||
_DRY_RUN_LEDGER.clear()
|
||||
|
||||
|
||||
def assess_apply_authorization(
|
||||
*,
|
||||
pr_number: int,
|
||||
repository_slug: str | None,
|
||||
resolved_task: str | None,
|
||||
active_role_kind: str | None,
|
||||
assessment: dict[str, Any],
|
||||
evidence: dict[str, Any] | None,
|
||||
expected_session_id: str | None = None,
|
||||
expected_candidate_head: str | None = None,
|
||||
expected_lease_comment_id: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a moot-lease cleanup apply is authorized (fail closed).
|
||||
|
||||
Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``.
|
||||
``allowed`` is True only when every check passes; each failure contributes a
|
||||
reason so the caller can report all of them together.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
blocker_kind: str | None = None
|
||||
|
||||
def _block(kind: str, reason: str) -> None:
|
||||
nonlocal blocker_kind
|
||||
reasons.append(reason)
|
||||
if blocker_kind is None:
|
||||
blocker_kind = kind
|
||||
|
||||
# 1. Exact resolved cleanup task. Resolving any other task — including a
|
||||
# sibling reconciler task — does not authorize this mutation.
|
||||
if _norm(resolved_task) != CLEANUP_TASK:
|
||||
_block(
|
||||
"unresolved_cleanup_task",
|
||||
"post-merge moot-lease cleanup requires the session to resolve "
|
||||
f"task '{CLEANUP_TASK}' immediately before apply; resolved task is "
|
||||
f"{resolved_task!r} (fail closed)",
|
||||
)
|
||||
|
||||
# 2. Dedicated reconciler role, enforced independently of the permission.
|
||||
if _norm(active_role_kind) != REQUIRED_ROLE:
|
||||
_block(
|
||||
"wrong_role",
|
||||
f"profile role {active_role_kind!r} cannot apply post-merge "
|
||||
f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when "
|
||||
f"{REQUIRED_PERMISSION} is present (fail closed)",
|
||||
)
|
||||
|
||||
# 3. Canonical repository identity must be established, never inferred from
|
||||
# request parameters.
|
||||
if not _norm(repository_slug):
|
||||
_block(
|
||||
"repository_binding",
|
||||
"no canonical repository identity could be established for the "
|
||||
"cleanup target (fail closed)",
|
||||
)
|
||||
|
||||
# 4. The live safety assessment must still say the lease is moot/cleanable.
|
||||
if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"):
|
||||
_block(
|
||||
"lease_not_moot",
|
||||
"live assessment does not report a moot, cleanable lease on PR "
|
||||
f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, "
|
||||
f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) "
|
||||
"(fail closed)",
|
||||
)
|
||||
|
||||
live = assessment.get("active_lease") or {}
|
||||
live_session = _norm(live.get("session_id"))
|
||||
live_head = _norm(live.get("candidate_head"))
|
||||
live_comment_id = _norm_comment_id(live.get("comment_id"))
|
||||
|
||||
# 5. A lease missing identifying fields is malformed and unsafe to act on.
|
||||
if not live_session or not live_head or live_comment_id is None:
|
||||
_block(
|
||||
"malformed_lease",
|
||||
"active lease is malformed: session_id / candidate_head / "
|
||||
"comment_id must all be present to authorize cleanup "
|
||||
f"(session_id={live.get('session_id')!r}, "
|
||||
f"candidate_head={live.get('candidate_head')!r}, "
|
||||
f"comment_id={live.get('comment_id')!r}) (fail closed)",
|
||||
)
|
||||
|
||||
# 6. Caller expectations, when supplied, must match the live lease exactly.
|
||||
if expected_session_id is not None and _norm(expected_session_id) != live_session:
|
||||
_block(
|
||||
"lease_mismatch",
|
||||
f"expected lease session {expected_session_id!r} does not match the "
|
||||
f"live lease session {live.get('session_id')!r} (fail closed)",
|
||||
)
|
||||
if (
|
||||
expected_candidate_head is not None
|
||||
and _norm(expected_candidate_head) != live_head
|
||||
):
|
||||
_block(
|
||||
"lease_mismatch",
|
||||
f"expected candidate head {expected_candidate_head!r} does not "
|
||||
f"match the live lease head {live.get('candidate_head')!r} "
|
||||
"(fail closed)",
|
||||
)
|
||||
if expected_lease_comment_id is not None and (
|
||||
_norm_comment_id(expected_lease_comment_id) != live_comment_id
|
||||
):
|
||||
_block(
|
||||
"lease_mismatch",
|
||||
f"expected lease marker {expected_lease_comment_id!r} does not "
|
||||
f"match the live lease marker {live.get('comment_id')!r} "
|
||||
"(fail closed)",
|
||||
)
|
||||
|
||||
# 7. Matching dry-run evidence recorded earlier in this session.
|
||||
evidence_matched = False
|
||||
if evidence is None:
|
||||
_block(
|
||||
"missing_dry_run_evidence",
|
||||
"no read-only dry run recorded for this repository and PR; run the "
|
||||
"tool with apply=false and confirm lease_moot / cleanup_allowed "
|
||||
"before applying (fail closed)",
|
||||
)
|
||||
elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"):
|
||||
_block(
|
||||
"dry_run_not_allowed",
|
||||
"recorded dry run did not report an allowed cleanup "
|
||||
f"(lease_moot={bool(evidence.get('lease_moot'))}, "
|
||||
f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) "
|
||||
"(fail closed)",
|
||||
)
|
||||
elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm(
|
||||
evidence.get("repository_slug")
|
||||
) != _norm(repository_slug):
|
||||
_block(
|
||||
"dry_run_mismatch",
|
||||
"recorded dry run targets a different repository or PR "
|
||||
f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs "
|
||||
f"{repository_slug}#{pr_number}) (fail closed)",
|
||||
)
|
||||
elif (
|
||||
_norm(evidence.get("session_id")) != live_session
|
||||
or _norm(evidence.get("candidate_head")) != live_head
|
||||
or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id
|
||||
):
|
||||
_block(
|
||||
"superseded_lease",
|
||||
"the lease changed after the recorded dry run (dry run: "
|
||||
f"session={evidence.get('session_id')!r}, "
|
||||
f"head={evidence.get('candidate_head')!r}, "
|
||||
f"marker={evidence.get('lease_comment_id')!r}; live: "
|
||||
f"session={live.get('session_id')!r}, "
|
||||
f"head={live.get('candidate_head')!r}, "
|
||||
f"marker={live.get('comment_id')!r}); re-run the dry run "
|
||||
"(fail closed)",
|
||||
)
|
||||
else:
|
||||
evidence_matched = True
|
||||
|
||||
return {
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"blocker_kind": blocker_kind,
|
||||
"evidence_matched": evidence_matched,
|
||||
"required_task": CLEANUP_TASK,
|
||||
"required_role_kind": REQUIRED_ROLE,
|
||||
"required_permission": REQUIRED_PERMISSION,
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
||||
|
||||
Pure assessment helpers for:
|
||||
|
||||
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
||||
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
||||
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
||||
of both PR head and live base head; never rebase/force-push.
|
||||
|
||||
All recommendation logic is hermetic (no network) so unit tests cover every
|
||||
acceptance path without Gitea credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
# Recommended next actions (controller / skill vocabulary).
|
||||
ACTION_MERGE_NOW = "merge_now"
|
||||
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
||||
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
||||
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
||||
ACTION_BLOCKED = "blocked"
|
||||
|
||||
_VALID_ACTIONS = frozenset({
|
||||
ACTION_MERGE_NOW,
|
||||
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||
ACTION_FRESH_REVIEW_REQUIRED,
|
||||
ACTION_BLOCKED,
|
||||
})
|
||||
|
||||
# Author-only mutation; reviewer/merger must never update author branches.
|
||||
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
||||
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
||||
|
||||
# Allowed Gitea update style — merge only (never rebase).
|
||||
UPDATE_STYLE_MERGE = "merge"
|
||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||
|
||||
# ── Commit check classifications (#751) ──────────────────────────────────
|
||||
# Gitea's *combined* commit status reports ``state: pending`` both when a real
|
||||
# check is executing and when the status-context collection is empty. Reading
|
||||
# ``state`` alone therefore cannot distinguish "CI is running" from "no CI
|
||||
# exists", which permanently blocks a merge-ready PR that no check will ever
|
||||
# report on. These classifications are derived from the actual context
|
||||
# collection plus the live branch-protection policy.
|
||||
CHECKS_SUCCESS = "success"
|
||||
CHECKS_FAILURE = "failure"
|
||||
CHECKS_PENDING = "pending"
|
||||
CHECKS_NONE = "none" # configured/produced nothing
|
||||
CHECKS_NOT_REQUIRED = "not_required" # protection does not require checks
|
||||
CHECKS_MISSING_REQUIRED = "missing_required" # required contexts have no result
|
||||
CHECKS_UNKNOWN = "unknown" # indeterminable — fail closed
|
||||
|
||||
# Values that permit merge_now when checks are required.
|
||||
_CHECKS_OK = frozenset({"success", "passed", "ok", "skipped", "not_required"})
|
||||
|
||||
# Raw per-context state vocabularies reported by Gitea.
|
||||
_CTX_SUCCESS = frozenset({"success", "passed", "ok"})
|
||||
_CTX_FAILURE = frozenset({"failure", "failed", "error", "cancelled", "canceled"})
|
||||
_CTX_PENDING = frozenset({"pending", "running", "queued", "expected"})
|
||||
_CTX_SKIPPED = frozenset({"skipped", "neutral"})
|
||||
|
||||
|
||||
def _normalize_context_rows(statuses: Any) -> list[dict[str, str]]:
|
||||
"""Reduce a raw status collection to newest-wins ``{context, state}`` rows.
|
||||
|
||||
Gitea returns the status collection newest-first, so the first row seen for
|
||||
a context wins. Rows without a usable state are discarded rather than being
|
||||
silently treated as passing.
|
||||
"""
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
if not isinstance(statuses, list):
|
||||
return rows
|
||||
for raw in statuses:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
context = (raw.get("context") or raw.get("name") or "").strip()
|
||||
state = (raw.get("status") or raw.get("state") or "").strip().lower()
|
||||
if not state:
|
||||
continue
|
||||
key = context or f"__unnamed__{len(rows)}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append({"context": context, "state": state})
|
||||
return rows
|
||||
|
||||
|
||||
def _aggregate_context_states(rows: list[dict[str, str]]) -> str:
|
||||
"""Fail-closed aggregate: failure > pending > unknown-state > success."""
|
||||
states = {row["state"] for row in rows}
|
||||
if states & _CTX_FAILURE:
|
||||
return CHECKS_FAILURE
|
||||
if states & _CTX_PENDING:
|
||||
return CHECKS_PENDING
|
||||
unresolved = states - _CTX_SUCCESS - _CTX_SKIPPED
|
||||
if unresolved:
|
||||
# An unrecognized context state must never read as success.
|
||||
return CHECKS_UNKNOWN
|
||||
return CHECKS_SUCCESS
|
||||
|
||||
|
||||
def classify_commit_checks(
|
||||
*,
|
||||
combined_state: str | None = None,
|
||||
statuses: Any = None,
|
||||
checks_enabled: bool | None = None,
|
||||
required_contexts: Any = None,
|
||||
policy_determinable: bool = True,
|
||||
status_determinable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify head checks from live evidence (#751).
|
||||
|
||||
``combined_state`` is deliberately **not** authoritative: it is recorded for
|
||||
observability but never used to infer that CI is executing. The context
|
||||
collection and the live protection policy decide.
|
||||
|
||||
Returns ``checks_status`` (one of the ``CHECKS_*`` values), the derived
|
||||
``checks_required`` flag, and structured ``reasons``.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
rows = _normalize_context_rows(statuses)
|
||||
required = [
|
||||
str(ctx).strip()
|
||||
for ctx in (required_contexts or [])
|
||||
if str(ctx or "").strip()
|
||||
]
|
||||
observed_combined = (combined_state or "").strip().lower() or None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"checks_status": CHECKS_UNKNOWN,
|
||||
"checks_required": True,
|
||||
"combined_state": observed_combined,
|
||||
"context_count": len(rows),
|
||||
"observed_contexts": [row["context"] for row in rows],
|
||||
"required_contexts": required,
|
||||
"missing_required_contexts": [],
|
||||
"policy_determinable": bool(policy_determinable),
|
||||
"status_determinable": bool(status_determinable),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
# Policy unreadable → never assume checks are optional.
|
||||
if not policy_determinable:
|
||||
reasons.append(
|
||||
"branch-protection check policy could not be read; cannot prove "
|
||||
"whether status checks are required (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if checks_enabled is False:
|
||||
result["checks_required"] = False
|
||||
result["checks_status"] = CHECKS_NOT_REQUIRED
|
||||
reasons.append(
|
||||
"live branch protection does not require status checks for the base "
|
||||
"branch; head status contexts do not gate merge"
|
||||
)
|
||||
return result
|
||||
|
||||
if checks_enabled is None:
|
||||
reasons.append(
|
||||
"branch-protection status-check requirement is indeterminate "
|
||||
"(fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Checks are required from here on.
|
||||
if not status_determinable:
|
||||
reasons.append(
|
||||
"head commit status collection could not be read while branch "
|
||||
"protection requires status checks (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if required:
|
||||
by_context = {row["context"]: row["state"] for row in rows if row["context"]}
|
||||
missing = [ctx for ctx in required if ctx not in by_context]
|
||||
if missing:
|
||||
result["missing_required_contexts"] = missing
|
||||
result["checks_status"] = CHECKS_MISSING_REQUIRED
|
||||
reasons.append(
|
||||
"branch protection requires status context(s) "
|
||||
f"{', '.join(missing)} but no matching status result exists at "
|
||||
"the head commit (fail closed)"
|
||||
)
|
||||
return result
|
||||
matched = [
|
||||
{"context": ctx, "state": by_context[ctx]} for ctx in required
|
||||
]
|
||||
result["checks_status"] = _aggregate_context_states(matched)
|
||||
reasons.append(
|
||||
f"evaluated {len(matched)} required status context(s) from live "
|
||||
"branch protection; unrelated contexts were ignored"
|
||||
)
|
||||
return result
|
||||
|
||||
# Status checks enabled with no specific required contexts configured.
|
||||
if not rows:
|
||||
result["checks_status"] = CHECKS_NONE
|
||||
reasons.append(
|
||||
"branch protection enables status checks but no status context was "
|
||||
"produced for the head commit"
|
||||
)
|
||||
if observed_combined in _CTX_PENDING:
|
||||
reasons.append(
|
||||
f"combined commit state '{observed_combined}' does not indicate "
|
||||
"executing CI because the status-context collection is empty"
|
||||
)
|
||||
return result
|
||||
|
||||
result["checks_status"] = _aggregate_context_states(rows)
|
||||
reasons.append(
|
||||
f"aggregated {len(rows)} reported status context(s); branch protection "
|
||||
"configures no explicit required-context list"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text if _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _normalize_role(role: str | None) -> str:
|
||||
return (role or "").strip().lower() or "unknown"
|
||||
|
||||
|
||||
def assess_pr_sync_status(
|
||||
*,
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
pr_number: int,
|
||||
pr_state: str | None = None,
|
||||
source_branch: str | None = None,
|
||||
pr_head_sha: str | None = None,
|
||||
base_head_sha: str | None = None,
|
||||
commits_behind: int | None = None,
|
||||
mergeable: bool | None = None,
|
||||
has_conflicts: bool | None = None,
|
||||
branch_protection_requires_current_base: bool | None = None,
|
||||
approval_at_current_head: bool | None = None,
|
||||
checks_status: str | None = None,
|
||||
active_author_lock: bool | None = None,
|
||||
active_reviewer_lease: bool | None = None,
|
||||
active_merger_lease: bool | None = None,
|
||||
prepared_verdict_head_sha: str | None = None,
|
||||
checks_required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Recommend the next sanctioned PR lifecycle action.
|
||||
|
||||
Decision order (fail closed):
|
||||
|
||||
1. Incomplete identity / open state / head SHAs → ``blocked``
|
||||
2. Conflicts (or mergeable false with conflict signal) →
|
||||
``author_conflict_remediation``
|
||||
3. Head changed after approval / prepared verdict for former head →
|
||||
``fresh_review_required`` (unless conflicts already routed author work)
|
||||
4. Behind live base + branch protection requires current base +
|
||||
auto-mergeable → ``update_branch_by_merge``
|
||||
5. Approval at current head + mergeable + checks ok (+ update not
|
||||
required) → ``merge_now``
|
||||
6. Otherwise ``blocked`` with structured reasons
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
pr_head = _normalize_sha(pr_head_sha)
|
||||
base_head = _normalize_sha(base_head_sha)
|
||||
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
||||
state = (pr_state or "").strip().lower()
|
||||
checks = (checks_status or "unknown").strip().lower()
|
||||
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
||||
requires_current = bool(branch_protection_requires_current_base)
|
||||
approval_ok = bool(approval_at_current_head)
|
||||
|
||||
# Derive conflict when caller only supplies mergeable=False.
|
||||
if has_conflicts is None and mergeable is False:
|
||||
has_conflicts = True
|
||||
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"host": (host or "").strip() or None,
|
||||
"org": (org or "").strip() or None,
|
||||
"repo": (repo or "").strip() or None,
|
||||
"pr_number": pr_number,
|
||||
"pr_state": state or None,
|
||||
"source_branch": (source_branch or "").strip() or None,
|
||||
"pr_head_sha": pr_head,
|
||||
"base_head_sha": base_head,
|
||||
"commits_behind": behind,
|
||||
"mergeable": mergeable,
|
||||
"has_conflicts": conflicts,
|
||||
"branch_protection_requires_current_base": requires_current,
|
||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||
"checks_status": checks,
|
||||
"checks_required": bool(checks_required),
|
||||
"active_locks_and_leases": {
|
||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
||||
},
|
||||
"prepared_verdict_head_sha": prepared_head,
|
||||
"recommended_next_action": ACTION_BLOCKED,
|
||||
"approval_valid_for_merge": False,
|
||||
"stale_approval": False,
|
||||
"stale_prepared_verdict": False,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
"performed": False,
|
||||
}
|
||||
|
||||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||
reasons.append("pr_number must be a positive integer (fail closed)")
|
||||
return result
|
||||
|
||||
if state and state not in ("open",):
|
||||
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
if not state:
|
||||
reasons.append("PR state missing (fail closed)")
|
||||
return result
|
||||
|
||||
if pr_head is None:
|
||||
reasons.append(
|
||||
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||
)
|
||||
if base_head is None:
|
||||
reasons.append(
|
||||
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||
)
|
||||
if behind is None:
|
||||
reasons.append("commits_behind missing or invalid (fail closed)")
|
||||
if mergeable is None:
|
||||
reasons.append("mergeable signal missing (fail closed)")
|
||||
if approval_at_current_head is None:
|
||||
reasons.append("approval_at_current_head missing (fail closed)")
|
||||
if branch_protection_requires_current_base is None:
|
||||
reasons.append(
|
||||
"branch_protection_requires_current_base missing (fail closed)"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# Stale prepared verdict / approval across heads.
|
||||
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
||||
result["stale_prepared_verdict"] = stale_prepared
|
||||
stale_approval = not approval_ok
|
||||
result["stale_approval"] = stale_approval
|
||||
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
||||
|
||||
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
||||
if conflicts is True or mergeable is False:
|
||||
if conflicts is True or mergeable is False:
|
||||
# Distinguish pure non-mergeable without explicit conflict flag
|
||||
# still routes author remediation (Gitea cannot auto-update).
|
||||
reasons.append(
|
||||
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
||||
f"{pr_head}; route author conflict remediation in the existing "
|
||||
"issue/PR worktree under branches/ (never force-push or rebase)"
|
||||
)
|
||||
if stale_approval:
|
||||
reasons.append(
|
||||
"any approval at a former head is invalid; after remediation "
|
||||
"require a fresh independent review at the new exact head"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
|
||||
# ── Stale approval / prepared verdict at former head ─────────────────
|
||||
if not approval_ok or stale_prepared:
|
||||
if behind and behind > 0 and requires_current and mergeable is True:
|
||||
# Must update first; update will also invalidate approval.
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base and branch protection "
|
||||
"requires current base; automatic merge-from-base is available"
|
||||
)
|
||||
reasons.append(
|
||||
"approval is not valid at the current head (or prepared verdict "
|
||||
"is head-scoped to a former SHA); after update require fresh review"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
reasons.append(
|
||||
"approval is not valid at the exact current PR head "
|
||||
f"({pr_head}); never reuse a former-head approval for merge"
|
||||
)
|
||||
if stale_prepared:
|
||||
reasons.append(
|
||||
f"prepared verdict pinned to former head {prepared_head} cannot "
|
||||
f"authorize review/merge at current head {pr_head}"
|
||||
)
|
||||
if active_reviewer_lease:
|
||||
reasons.append(
|
||||
"active reviewer lease must be released or superseded for the "
|
||||
"new head before a fresh independent review"
|
||||
)
|
||||
if active_merger_lease:
|
||||
reasons.append(
|
||||
"active merger lease cannot cross heads; release or re-acquire "
|
||||
"at the new exact head"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
|
||||
# ── Outdated + protection requires current base, auto-updatable ──────
|
||||
if behind and behind > 0 and requires_current:
|
||||
if mergeable is True and conflicts is not True:
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base {base_head}; "
|
||||
"branch protection requires the PR branch to contain current base; "
|
||||
"Gitea can merge base into the PR branch without conflicts"
|
||||
)
|
||||
reasons.append(
|
||||
"route author-only update_branch_by_merge; never rebase or force-push; "
|
||||
"new head invalidates prior approval and requires fresh independent review"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
# Approval today is at old head that will change — not mergeable yet
|
||||
# for final merge until re-reviewed, but assess marks update path.
|
||||
result["approval_valid_for_merge"] = False
|
||||
result["stale_approval"] = True # will become stale after update
|
||||
return result
|
||||
reasons.append(
|
||||
"update required by branch protection but automatic merge is not available"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||
# ``checks_required`` is derived from the live branch-protection policy by
|
||||
# the production caller. When protection does not require status checks,
|
||||
# head contexts cannot gate the merge and this whole gate is skipped.
|
||||
if not checks_required:
|
||||
reasons.append(
|
||||
"live branch protection does not require status checks; head check "
|
||||
f"state ({checks}) does not gate merge"
|
||||
)
|
||||
elif checks not in _CHECKS_OK:
|
||||
if checks in _CTX_PENDING:
|
||||
reasons.append(f"required checks are not finished (status={checks})")
|
||||
elif checks in _CTX_FAILURE:
|
||||
reasons.append(f"required checks failed (status={checks})")
|
||||
elif checks == CHECKS_MISSING_REQUIRED:
|
||||
reasons.append(
|
||||
"branch protection configures required status context(s) but no "
|
||||
"matching status result exists at the current head (fail closed)"
|
||||
)
|
||||
elif checks == CHECKS_NONE:
|
||||
reasons.append(
|
||||
"branch protection requires status checks but no status context "
|
||||
"was produced for the current head (fail closed); an empty "
|
||||
"status collection is not executing CI"
|
||||
)
|
||||
elif checks == CHECKS_UNKNOWN:
|
||||
reasons.append("checks status unknown (fail closed)")
|
||||
else:
|
||||
# Unrecognized vocabulary must never fall through to merge_now.
|
||||
reasons.append(
|
||||
f"unrecognized checks status '{checks}' cannot prove required "
|
||||
"checks passed (fail closed)"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Ready to merge without update ────────────────────────────────────
|
||||
# Includes: current with approval; outdated when update is NOT required.
|
||||
if approval_ok and mergeable is True and conflicts is not True:
|
||||
if behind and behind > 0 and not requires_current:
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base but branch protection "
|
||||
"does not require the latest base; preserve the approved head"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"PR has a valid approval at the exact current head, is conflict-free "
|
||||
"and mergeable; do not update the branch unnecessarily"
|
||||
)
|
||||
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
||||
result["recommended_next_action"] = ACTION_MERGE_NOW
|
||||
result["approval_valid_for_merge"] = True
|
||||
return result
|
||||
|
||||
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
|
||||
def assess_update_pr_branch_preflight(
|
||||
*,
|
||||
role_kind: str | None,
|
||||
expected_pr_head_sha: str | None,
|
||||
live_pr_head_sha: str | None,
|
||||
expected_base_head_sha: str | None,
|
||||
live_base_head_sha: str | None,
|
||||
has_conflicts: bool | None = None,
|
||||
mergeable: bool | None = None,
|
||||
style: str | None = UPDATE_STYLE_MERGE,
|
||||
has_author_lock: bool | None = None,
|
||||
worktree_path: str | None = None,
|
||||
worktree_owns_source_branch: bool | None = None,
|
||||
control_checkout_clean: bool | None = None,
|
||||
master_parity_ok: bool | None = None,
|
||||
force_push: bool = False,
|
||||
rebase: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Author-only preflight for update-by-merge; fail closed on any race.
|
||||
|
||||
When conflicts exist, returns a structured author-remediation handoff and
|
||||
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
role = _normalize_role(role_kind)
|
||||
exp_pr = _normalize_sha(expected_pr_head_sha)
|
||||
live_pr = _normalize_sha(live_pr_head_sha)
|
||||
exp_base = _normalize_sha(expected_base_head_sha)
|
||||
live_base = _normalize_sha(live_base_head_sha)
|
||||
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
||||
|
||||
conflicts = has_conflicts
|
||||
if conflicts is None and mergeable is False:
|
||||
conflicts = True
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"mutation_allowed": False,
|
||||
"performed": False,
|
||||
"style": style_norm,
|
||||
"role_kind": role,
|
||||
"expected_pr_head_sha": exp_pr,
|
||||
"live_pr_head_sha": live_pr,
|
||||
"expected_base_head_sha": exp_base,
|
||||
"live_base_head_sha": live_base,
|
||||
"head_race": False,
|
||||
"base_race": False,
|
||||
"has_conflicts": conflicts,
|
||||
"author_remediation_handoff": False,
|
||||
"approval_invalidated_for_former_head": False,
|
||||
"force_push": bool(force_push),
|
||||
"rebase": bool(rebase),
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
# Role gate — author only.
|
||||
if role not in _AUTHOR_UPDATE_ROLES:
|
||||
reasons.append(
|
||||
f"role '{role}' cannot update an author PR branch "
|
||||
"(author-only; reviewer/merger denial)"
|
||||
)
|
||||
return result
|
||||
|
||||
if force_push:
|
||||
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
||||
return result
|
||||
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
||||
reasons.append(
|
||||
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
||||
"(never rebase)"
|
||||
)
|
||||
return result
|
||||
if style_norm != UPDATE_STYLE_MERGE:
|
||||
reasons.append(
|
||||
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
||||
)
|
||||
return result
|
||||
|
||||
if not exp_pr or not live_pr:
|
||||
reasons.append(
|
||||
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
||||
)
|
||||
if not exp_base or not live_base:
|
||||
reasons.append(
|
||||
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
||||
)
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
if exp_pr != live_pr:
|
||||
result["head_race"] = True
|
||||
reasons.append(
|
||||
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
||||
"(fail closed; no partial mutation)"
|
||||
)
|
||||
return result
|
||||
if exp_base != live_base:
|
||||
result["base_race"] = True
|
||||
reasons.append(
|
||||
f"base head race: expected {exp_base} but live is {live_base} "
|
||||
"(fail closed; no partial mutation)"
|
||||
)
|
||||
return result
|
||||
|
||||
if has_author_lock is not True:
|
||||
reasons.append(
|
||||
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
||||
)
|
||||
wt = (worktree_path or "").strip()
|
||||
if not wt:
|
||||
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
||||
else:
|
||||
norm = wt.replace("\\", "/")
|
||||
if "/branches/" not in norm and not norm.startswith("branches/"):
|
||||
reasons.append(
|
||||
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
||||
)
|
||||
if worktree_owns_source_branch is False:
|
||||
reasons.append(
|
||||
"worktree does not own the PR source branch (fail closed)"
|
||||
)
|
||||
if control_checkout_clean is False:
|
||||
reasons.append("control checkout is not clean (fail closed)")
|
||||
if master_parity_ok is False:
|
||||
reasons.append("runtime/master parity failed (fail closed)")
|
||||
|
||||
if conflicts is True:
|
||||
result["author_remediation_handoff"] = True
|
||||
reasons.append(
|
||||
"conflicts present: return structured author-remediation handoff "
|
||||
"without creating a partial remote update"
|
||||
)
|
||||
reasons.append(
|
||||
"resolve conflicts explicitly in the existing dedicated worktree, "
|
||||
"run focused and regression tests, commit/push via sanctioned author "
|
||||
"workflow, then route the new exact head to independent review"
|
||||
)
|
||||
return result
|
||||
|
||||
if mergeable is False and conflicts is not False:
|
||||
result["author_remediation_handoff"] = True
|
||||
reasons.append(
|
||||
"PR is not mergeable; refuse automatic update and hand off to "
|
||||
"author conflict remediation (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
result["mutation_allowed"] = True
|
||||
reasons.append(
|
||||
"preflight passed: author may merge live base into the PR branch via "
|
||||
"native MCP update (style=merge only)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def assess_post_update_head_transition(
|
||||
*,
|
||||
former_pr_head_sha: str | None,
|
||||
new_pr_head_sha: str | None,
|
||||
former_approval_head_sha: str | None = None,
|
||||
former_reviewer_lease_head_sha: str | None = None,
|
||||
former_merger_lease_head_sha: str | None = None,
|
||||
prepared_verdict_head_sha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
||||
|
||||
The new head never inherits approval, reviewer/merger leases, or prepared
|
||||
verdicts from the former head.
|
||||
"""
|
||||
former = _normalize_sha(former_pr_head_sha)
|
||||
new = _normalize_sha(new_pr_head_sha)
|
||||
reasons: list[str] = []
|
||||
result: dict[str, Any] = {
|
||||
"former_pr_head_sha": former,
|
||||
"new_pr_head_sha": new,
|
||||
"head_changed": bool(former and new and former != new),
|
||||
"approval_invalidated": False,
|
||||
"reviewer_lease_superseded": False,
|
||||
"merger_lease_superseded": False,
|
||||
"prepared_verdict_invalidated": False,
|
||||
"recommended_next_action": ACTION_BLOCKED,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
if not former or not new:
|
||||
reasons.append("former and new PR head SHAs required (fail closed)")
|
||||
return result
|
||||
if former == new:
|
||||
reasons.append(
|
||||
"PR head unchanged after update; unexpected for merge-from-base"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
result["head_changed"] = True
|
||||
# Approval at former head is always void at new head.
|
||||
former_approval = _normalize_sha(former_approval_head_sha) or former
|
||||
if former_approval != new:
|
||||
result["approval_invalidated"] = True
|
||||
reasons.append(
|
||||
f"approval for former head {former_approval} is invalid at new head "
|
||||
f"{new}; never preserve approval across a head change"
|
||||
)
|
||||
|
||||
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
||||
if rev_lease and rev_lease != new:
|
||||
result["reviewer_lease_superseded"] = True
|
||||
reasons.append(
|
||||
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
||||
"release or supersede before fresh review"
|
||||
)
|
||||
elif rev_lease is None:
|
||||
# Unknown lease head still must not authorize at new head.
|
||||
result["reviewer_lease_superseded"] = True
|
||||
reasons.append(
|
||||
"any reviewer lease scoped to the former head is superseded at the new head"
|
||||
)
|
||||
|
||||
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
||||
if mer_lease and mer_lease != new:
|
||||
result["merger_lease_superseded"] = True
|
||||
reasons.append(
|
||||
f"merger lease for head {mer_lease} cannot cross to {new}"
|
||||
)
|
||||
else:
|
||||
result["merger_lease_superseded"] = True
|
||||
reasons.append(
|
||||
"any merger lease scoped to the former head is superseded at the new head"
|
||||
)
|
||||
|
||||
prepared = _normalize_sha(prepared_verdict_head_sha)
|
||||
if prepared and prepared != new:
|
||||
result["prepared_verdict_invalidated"] = True
|
||||
reasons.append(
|
||||
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
||||
)
|
||||
elif prepared is None:
|
||||
result["prepared_verdict_invalidated"] = True
|
||||
reasons.append(
|
||||
"prepared verdicts associated with the former head are invalidated"
|
||||
)
|
||||
|
||||
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||
reasons.append(
|
||||
"route the new exact head to independent review "
|
||||
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def assess_sequential_queue_step(
|
||||
*,
|
||||
just_merged: bool,
|
||||
master_refreshed: bool,
|
||||
next_pr_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Controller sequential processing: reassess only after merge + master refresh."""
|
||||
reasons: list[str] = []
|
||||
if just_merged and not master_refreshed:
|
||||
reasons.append(
|
||||
"master must be refreshed after each merge before reassessing the next PR "
|
||||
"(do not update every PR simultaneously)"
|
||||
)
|
||||
return {
|
||||
"reassess_allowed": False,
|
||||
"process_next": False,
|
||||
"next_pr_number": next_pr_number,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
if not just_merged:
|
||||
reasons.append("no merge completed; sequential step does not advance")
|
||||
return {
|
||||
"reassess_allowed": False,
|
||||
"process_next": False,
|
||||
"next_pr_number": next_pr_number,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
if next_pr_number:
|
||||
reasons.append(
|
||||
"merge completed and live master refreshed; reassess the next PR "
|
||||
f"#{next_pr_number}"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"merge completed and live master refreshed; reassess the next PR"
|
||||
)
|
||||
return {
|
||||
"reassess_allowed": True,
|
||||
"process_next": True,
|
||||
"next_pr_number": next_pr_number,
|
||||
"post_merge_cleanup_handoff": True,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
def merge_approval_usable_at_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
approved_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stale approval cannot authorize merge (head-scoped only)."""
|
||||
current = _normalize_sha(current_head_sha)
|
||||
approved = _normalize_sha(approved_head_sha)
|
||||
ok = bool(current and approved and current == approved)
|
||||
reasons: list[str] = []
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"stale approval: approved head '{approved or '(none)'}' does not "
|
||||
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
||||
)
|
||||
return {
|
||||
"approval_usable": ok,
|
||||
"current_head_sha": current,
|
||||
"approved_head_sha": approved,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def lease_usable_at_head(
|
||||
*,
|
||||
lease_kind: str,
|
||||
lease_head_sha: str | None,
|
||||
current_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer/merger leases cannot cross heads."""
|
||||
current = _normalize_sha(current_head_sha)
|
||||
lease_head = _normalize_sha(lease_head_sha)
|
||||
kind = (lease_kind or "").strip().lower() or "unknown"
|
||||
ok = bool(current and lease_head and current == lease_head)
|
||||
reasons: list[str] = []
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
||||
f"authorize work at current head '{current or '(none)'}'"
|
||||
)
|
||||
return {
|
||||
"lease_usable": ok,
|
||||
"lease_kind": kind,
|
||||
"lease_head_sha": lease_head,
|
||||
"current_head_sha": current,
|
||||
"reasons": reasons,
|
||||
}
|
||||
+56
-4
@@ -20,7 +20,11 @@ _FIELD_RE = re.compile(
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
# Must mirror reviewer_pr_lease._TERMINAL_PHASES: both modules read the same
|
||||
# append-only lease markers, so a phase that is terminal in one and active in
|
||||
# the other yields two conflicting truths for the same comment (#742 review
|
||||
# 460). "abandoned" is the owner-session merger finalization outcome.
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
@@ -32,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
||||
# The reviewer/merger PR-lease TTL lives in reviewer_pr_lease.LEASE_TTL_MINUTES
|
||||
# (#747). A second copy here had no readers and could only drift out of sync.
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
@@ -153,25 +158,72 @@ def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _reviewer_chain_key(lease: dict) -> tuple | None:
|
||||
"""Identity of the lease chain a reviewer marker belongs to (#742).
|
||||
|
||||
A chain is one session's claim → heartbeat → terminal sequence, keyed by
|
||||
repository, PR, candidate head, session id, identity, and profile. Returns
|
||||
None when any component is missing: an incomplete or malformed marker has
|
||||
no provable chain, so it can neither be cancelled by nor cancel anything.
|
||||
"""
|
||||
raw = lease.get("raw_fields") or {}
|
||||
repo = (raw.get("repo") or "").strip().lower()
|
||||
session_id = (lease.get("session_id") or "").strip()
|
||||
identity = (lease.get("reviewer_identity") or "").strip()
|
||||
profile = (lease.get("profile") or "").strip()
|
||||
head = lease.get("candidate_head")
|
||||
pr_number = lease.get("pr_number")
|
||||
if not (repo and session_id and identity and profile and head and pr_number):
|
||||
return None
|
||||
return (repo, pr_number, head, session_id, identity, profile)
|
||||
|
||||
|
||||
def _chain_terminated_after(entries: list[dict], index: int) -> bool:
|
||||
"""True when a later marker terminates the chain of ``entries[index]``.
|
||||
|
||||
Append-only newest-wins (#577 semantics, chain-scoped for #742): a terminal
|
||||
marker ends only its *own* claim, so a foreign, forged, or malformed
|
||||
terminal marker cannot cancel another session's valid active lease.
|
||||
"""
|
||||
key = _reviewer_chain_key(entries[index])
|
||||
if key is None:
|
||||
return False
|
||||
for later in entries[index + 1:]:
|
||||
if (later.get("phase") or "").strip().lower() not in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if _reviewer_chain_key(later) == key:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
"""Return the newest unexpired, non-terminated reviewer lease for *pr_number*.
|
||||
|
||||
Walking backward past a terminal marker used to resurrect the older claim of
|
||||
the very chain that marker ended, so a released/abandoned finalization still
|
||||
read as an active lease here while ``reviewer_pr_lease`` reported it ended
|
||||
(#742). A claim is now skipped when a later marker terminates its own chain.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
for index in range(len(candidates) - 1, -1, -1):
|
||||
lease = candidates[index]
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
if _chain_terminated_after(candidates, index):
|
||||
continue
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ python-multipart==0.0.32
|
||||
referencing==0.37.0
|
||||
rich==15.0.0
|
||||
rpds-py==2026.5.1
|
||||
sentry-sdk==2.20.0
|
||||
shellingham==1.5.4
|
||||
sse-starlette==3.4.5
|
||||
starlette==1.3.1
|
||||
|
||||
+285
-8
@@ -16,7 +16,10 @@ _FIELD_RE = re.compile(
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||
# "abandoned" is the owner-session merger finalization outcome (#742). Without
|
||||
# it here, an abandoned marker would fall through to the generic non-empty
|
||||
# phase branch and keep re-arming the lease as active.
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
|
||||
_ACTIVE_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
@@ -26,9 +29,19 @@ _ACTIVE_PHASES = frozenset({
|
||||
"adopted",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
STALE_WARNING_MINUTES = 30
|
||||
RECLAIMABLE_MINUTES = 60
|
||||
# Reviewer and merger PR leases use a short *sliding* window (#747): a lease
|
||||
# expires 10 minutes after its last heartbeat, and every heartbeat slides the
|
||||
# expiry forward. An actively heartbeating session is never evicted, while a
|
||||
# dead session releases its hold in at most one TTL instead of the two hours
|
||||
# the previous fixed 120-minute expiry allowed.
|
||||
LEASE_TTL_MINUTES = 10
|
||||
# Renewal is named separately from acquisition so the slide amount is tunable
|
||||
# without silently re-defining how long a fresh lease lives.
|
||||
LEASE_RENEWAL_MINUTES = 10
|
||||
# Retained for callers that imported the pre-#747 name.
|
||||
DEFAULT_LEASE_TTL_MINUTES = LEASE_TTL_MINUTES
|
||||
# Warn at half the window, while the owner can still heartbeat and recover.
|
||||
STALE_WARNING_MINUTES = 5
|
||||
|
||||
_SESSION_LEASE: dict[str, Any] | None = None
|
||||
|
||||
@@ -77,10 +90,19 @@ def format_lease_body(
|
||||
target_branch_sha: str | None,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
ttl_minutes: int = LEASE_TTL_MINUTES,
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
"""Serialize a lease marker.
|
||||
|
||||
Every write of this marker — acquisition, heartbeat, adoption — re-derives
|
||||
``expires_at`` from the moment of the write, which is what makes the TTL
|
||||
slide (#747). Callers renewing an existing lease pass
|
||||
``ttl_minutes=LEASE_RENEWAL_MINUTES``; an explicit ``expires_at`` still
|
||||
wins so a lease can be minted with a deliberate window.
|
||||
"""
|
||||
now = last_activity or datetime.now(timezone.utc)
|
||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
||||
expires = expires_at or (now + timedelta(minutes=ttl_minutes))
|
||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
@@ -166,8 +188,30 @@ def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||
return (now - last).total_seconds() / 60.0
|
||||
|
||||
|
||||
def lease_seconds_remaining(lease: dict, *, now: datetime | None = None) -> int | None:
|
||||
"""Seconds until *lease* expires, clamped at 0; ``None`` if unparsable.
|
||||
|
||||
Lets diagnostics distinguish "held and live" from "held and dying" (#747)
|
||||
rather than only reporting that a lease exists.
|
||||
"""
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
if not expires_at:
|
||||
return None
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return max(0, int((expires_at - now).total_seconds()))
|
||||
|
||||
|
||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
||||
"""Return active, stale_warning, expired, or terminal.
|
||||
|
||||
Expiry is the only takeover gate (#747). The pre-#747 ``reclaimable`` band
|
||||
sat between "stale" and "expired" and made a dead lease wait out a second
|
||||
timer before anyone could reclaim it. Under a sliding TTL that band is also
|
||||
unreachable: a heartbeat stamps ``last_activity`` and ``expires_at``
|
||||
together, so a lease idle for a full TTL is already expired. Foreign
|
||||
expired leases are handled by the ``foreign_expired`` classification, which
|
||||
carries the same sanctioned release next-action the old tier did.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
@@ -177,8 +221,6 @@ def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str
|
||||
minutes = _minutes_since_activity(lease, now=now)
|
||||
if minutes is None:
|
||||
return "active"
|
||||
if minutes >= RECLAIMABLE_MINUTES:
|
||||
return "reclaimable"
|
||||
if minutes >= STALE_WARNING_MINUTES:
|
||||
return "stale_warning"
|
||||
return "active"
|
||||
@@ -407,18 +449,194 @@ def record_session_lease(
|
||||
if lease_provenance:
|
||||
stored["lease_provenance"] = dict(lease_provenance)
|
||||
_SESSION_LEASE = stored
|
||||
_persist_session_lease_shadow(stored)
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
def clear_session_lease() -> None:
|
||||
global _SESSION_LEASE
|
||||
prior = _SESSION_LEASE
|
||||
_SESSION_LEASE = None
|
||||
_persist_session_lease_shadow(None, prior=prior)
|
||||
|
||||
|
||||
def get_session_lease() -> dict[str, Any] | None:
|
||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||
|
||||
|
||||
def _persist_session_lease_shadow(
|
||||
lease: dict[str, Any] | None,
|
||||
*,
|
||||
prior: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Best-effort durable shadow of the in-session lease (#702).
|
||||
|
||||
A daemon that dies without teardown leaves this record behind, giving a
|
||||
later process provable orphan evidence (owner pid + session id) for the
|
||||
guarded cleanup path. Observability only — mutation gates never read it,
|
||||
and failures here must never block lease operations.
|
||||
|
||||
Shadows are keyed by lease session id (#702 F5): concurrent same-profile
|
||||
sessions each own a distinct record, so one session's heartbeat can never
|
||||
overwrite or misattribute another's crash evidence. A sanctioned clear is
|
||||
the terminal reconciliation of that record's lifecycle.
|
||||
"""
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
if lease is None:
|
||||
sid = ((prior or {}).get("session_id") or "").strip() or None
|
||||
if sid:
|
||||
mss.clear_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||
)
|
||||
# Legacy single-slot record from pre-F5 code: clearing it is safe
|
||||
# because collision-safe writes never target that key again.
|
||||
mss.clear_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
return
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
instance_id=((lease.get("session_id") or "").strip() or None),
|
||||
payload={
|
||||
"pr_number": lease.get("pr_number"),
|
||||
"session_id": lease.get("session_id"),
|
||||
"comment_id": lease.get("comment_id"),
|
||||
"candidate_head": lease.get("candidate_head"),
|
||||
"worktree": lease.get("worktree"),
|
||||
"phase": lease.get("phase"),
|
||||
"reviewer_identity": lease.get("reviewer_identity"),
|
||||
"profile": lease.get("profile"),
|
||||
"lease_repo": lease.get("repo"),
|
||||
"owner_pid": os.getpid(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_session_lease_shadow(
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load the durable session-lease shadow left by this profile identity.
|
||||
|
||||
With *session_id*, load that session's collision-safe record (#702 F5),
|
||||
falling back to the legacy single-slot record only when its payload names
|
||||
the same session. Without *session_id*, return the legacy record or the
|
||||
sole surviving instance record — ambiguity (multiple candidates) returns
|
||||
``None`` because a shadow that cannot be attributed proves nothing.
|
||||
"""
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
sid = (session_id or "").strip()
|
||||
if sid:
|
||||
record = mss.load_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||
)
|
||||
if record is not None:
|
||||
return record
|
||||
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if legacy and (legacy.get("session_id") or "").strip() == sid:
|
||||
return legacy
|
||||
return None
|
||||
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if len(records) == 1:
|
||||
return records[0]
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Probe process existence; fail closed toward 'alive' when unsure."""
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def assess_crashed_session_lease_orphan(
|
||||
shadow: dict[str, Any] | None,
|
||||
*,
|
||||
lease: dict[str, Any] | None,
|
||||
current_pid: int | None = None,
|
||||
pid_alive: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Derive owner-process evidence for a durable lease from the shadow (#702).
|
||||
|
||||
Returns ``owner_process_alive`` tri-state. Only a provably dead recorded
|
||||
owner pid yields ``False`` (a dead pid cannot still be running the owning
|
||||
daemon); an alive pid is *not* ownership proof and stays ``None`` unless
|
||||
it is this very process. Session ids must match exactly — the shadow of a
|
||||
different lease proves nothing.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
if not shadow or not lease:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": ["no durable session-lease shadow evidence available"],
|
||||
}
|
||||
shadow_sid = (shadow.get("session_id") or "").strip()
|
||||
lease_sid = (lease.get("session_id") or "").strip()
|
||||
if not shadow_sid or not lease_sid or shadow_sid != lease_sid:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": [
|
||||
"session-lease shadow session_id does not match the durable "
|
||||
f"lease (shadow={shadow_sid or None}, lease={lease_sid or None})"
|
||||
],
|
||||
}
|
||||
owner_pid = shadow.get("owner_pid") or shadow.get("writer_pid")
|
||||
try:
|
||||
owner_pid = int(owner_pid)
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": ["session-lease shadow has no usable owner pid (fail closed)"],
|
||||
}
|
||||
this_pid = current_pid if current_pid is not None else os.getpid()
|
||||
if owner_pid == this_pid:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": True,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": ["shadow owner pid is the current process"],
|
||||
}
|
||||
alive = pid_alive if pid_alive is not None else _pid_alive(owner_pid)
|
||||
if alive:
|
||||
reasons.append(
|
||||
f"shadow owner pid {owner_pid} observed alive; PID liveness is not "
|
||||
"ownership proof — owner evidence stays unknown (fail closed)"
|
||||
)
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": reasons,
|
||||
}
|
||||
reasons.append(
|
||||
f"shadow owner pid {owner_pid} is dead: the daemon that recorded the "
|
||||
"matching session lease exited without teardown (crash orphan, #702)"
|
||||
)
|
||||
return {
|
||||
"orphan_evidence": True,
|
||||
"owner_process_alive": False,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def assess_mutation_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
@@ -557,6 +775,7 @@ _HANDOFF_CLASSIFICATIONS = frozenset({
|
||||
"foreign_completed_superseded_head",
|
||||
"foreign_expired_superseded_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
"ambiguous_conflicting_evidence",
|
||||
"instructed_lease_missing_with_replacement",
|
||||
"worktree_binding_mismatch",
|
||||
@@ -850,6 +1069,38 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
classification = "foreign_expired_superseded_head"
|
||||
elif head_superseded and has_terminal and not past_expiry:
|
||||
classification = "foreign_completed_superseded_head"
|
||||
elif head_superseded and not has_terminal and past_expiry:
|
||||
# Crash-orphan shape (#702): the lease outlived its head with no
|
||||
# formal terminal review and has now passed canonical expiry, so
|
||||
# it no longer gates fresh acquisition. Guarded cleanup of the
|
||||
# ledger marker still demands provable owner-process-exit
|
||||
# evidence and a safe worktree — never inferred.
|
||||
classification = "orphaned_expired_superseded_head"
|
||||
if owner_process_alive is True:
|
||||
fail_closed_reasons.append(
|
||||
"lease past expiry on superseded head but owner process "
|
||||
"still alive; cleanup denied"
|
||||
)
|
||||
elif owner_process_alive is None:
|
||||
fail_closed_reasons.append(
|
||||
"expired superseded-head lease with no terminal review: "
|
||||
"cleanup requires provable owner-process-exit evidence "
|
||||
"(owner_process_alive=false); the expired lease no longer "
|
||||
"gates fresh acquisition"
|
||||
)
|
||||
elif worktree_clean is False:
|
||||
fail_closed_reasons.append(
|
||||
"owner process absent but worktree dirty; cleanup denied"
|
||||
)
|
||||
elif not (worktree_clean is True or worktree_exists is False):
|
||||
# #702 F3: unknown worktree evidence must fail closed, exactly
|
||||
# like the sibling orphaned_owner_missing gate and the
|
||||
# diagnosis path — cleanup needs affirmative safe evidence.
|
||||
fail_closed_reasons.append(
|
||||
"owner process absent but worktree evidence is unknown; "
|
||||
"cleanup requires affirmative safe evidence "
|
||||
"(worktree_clean=true or worktree_exists=false)"
|
||||
)
|
||||
elif head_superseded and not has_terminal:
|
||||
classification = "ambiguous_conflicting_evidence"
|
||||
fail_closed_reasons.append(
|
||||
@@ -891,6 +1142,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
"foreign_expired_superseded_head",
|
||||
"foreign_expired_current_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
}:
|
||||
fail_closed_reasons.append(
|
||||
"controller/recovery capability required "
|
||||
@@ -909,6 +1161,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
"foreign_completed_superseded_head",
|
||||
"foreign_expired_superseded_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
"foreign_expired_current_head",
|
||||
}
|
||||
# foreign_expired_current_head needs orphan/clean worktree + authority.
|
||||
@@ -1181,6 +1434,30 @@ def diagnose_reviewer_pr_lease_handoff(
|
||||
"foreign lease pinned to superseded head with completed formal "
|
||||
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
|
||||
)
|
||||
elif not is_own and head_superseded and not terminal and past_expiry:
|
||||
# Crash-orphan shape after canonical expiry (#702): the expired
|
||||
# marker no longer gates acquisition, so a fresh reviewer may
|
||||
# acquire at the current head. Guarded ledger cleanup becomes
|
||||
# available only with provable owner-exit evidence.
|
||||
classification = "orphaned_expired_superseded_head"
|
||||
if owner_process_alive is False and (
|
||||
worktree_clean is True or worktree_exists is False
|
||||
):
|
||||
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||
reasons.append(
|
||||
"expired superseded-head lease with no terminal review and "
|
||||
"provable owner-process exit (crash orphan, #702); guarded "
|
||||
"obsolete-lease cleanup eligible"
|
||||
)
|
||||
else:
|
||||
next_action = NEXT_ACTION_ACQUIRE
|
||||
reasons.append(
|
||||
"lease head superseded and lease past canonical expiry with "
|
||||
"no formal terminal review (crash orphan, #702); expired "
|
||||
"marker no longer gates acquisition — acquire a fresh lease "
|
||||
"at the current head; guarded cleanup needs "
|
||||
"owner_process_alive=false and a clean/absent worktree"
|
||||
)
|
||||
elif not is_own and head_superseded and not terminal:
|
||||
classification = "ambiguous_conflicting_evidence"
|
||||
next_action = NEXT_ACTION_WAIT
|
||||
|
||||
+14
-2
@@ -76,7 +76,6 @@ AUTHOR_TASKS = frozenset({
|
||||
"create_pr",
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
"reconcile_landed_pr",
|
||||
@@ -84,6 +83,15 @@ AUTHOR_TASKS = frozenset({
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"cleanup_merged_pr_branch",
|
||||
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||
"delete_branch",
|
||||
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned; the
|
||||
# apply path posts a terminal lease marker. Kept in step with
|
||||
# task_capability_map so map and router cannot disagree (#723 defect A).
|
||||
"cleanup_post_merge_moot_lease",
|
||||
"gitea_cleanup_post_merge_moot_lease",
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_already_landed",
|
||||
"reconcile-landed-pr",
|
||||
@@ -99,7 +107,8 @@ TASK_REQUIRED_ROLE = {
|
||||
"create_pr": "author",
|
||||
"comment_pr": "author",
|
||||
"address_pr_change_requests": "author",
|
||||
"delete_branch": "author",
|
||||
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
||||
"delete_branch": "reconciler",
|
||||
"review_pr": "reviewer",
|
||||
"merge_pr": "merger",
|
||||
"blind_pr_queue_review": "reviewer",
|
||||
@@ -114,6 +123,9 @@ TASK_REQUIRED_ROLE = {
|
||||
"reconcile_already_landed": "reconciler",
|
||||
"reconcile-landed-pr": "reconciler",
|
||||
"cleanup_merged_pr_branch": "reconciler",
|
||||
# #745: post-merge moot reviewer-lease cleanup (canonical task + tool alias).
|
||||
"cleanup_post_merge_moot_lease": "reconciler",
|
||||
"gitea_cleanup_post_merge_moot_lease": "reconciler",
|
||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||
"reconcile_close_landed_pr": "reconciler",
|
||||
"reconcile_close_landed_issue": "reconciler",
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Fail-closed guard against manual MCP daemon process killing (#630).
|
||||
|
||||
Workflow recovery must use sanctioned reconnect/restart paths only: host
|
||||
auto-reconnect, an operator-owned restart, or the documented client relaunch. A
|
||||
session that instead runs ``pkill -f mcp_server.py`` has manipulated the very
|
||||
host processes its own proof depends on.
|
||||
|
||||
Incident origin: a session ran ``ps aux | grep mcp_server``, then
|
||||
``pkill -f mcp_server.py``, waited for the IDE to respawn the daemons, called
|
||||
MCP tools, and closed issue #601. Nothing distinguished that closure from one
|
||||
performed over a sanctioned runtime, and unrelated namespaces may have been
|
||||
killed as collateral damage.
|
||||
|
||||
Partial detection already existed — ``native_mcp_preference.classify_command_path``
|
||||
flags ``kill``/``pkill`` near ``mcp_server`` as an MCP-server touch, and
|
||||
``review_workflow_boundary`` classifies a pre-review ``pkill`` as MCP repair
|
||||
activity — but neither wrote a durable marker nor failed closed on the
|
||||
review / merge / close mutations that followed.
|
||||
|
||||
This module mirrors ``stable_branch_push_guard`` (#671) deliberately: same
|
||||
contamination-marker shape, same gated-task set, same reconciler-only clear.
|
||||
Like that guard it is **pure** — callers gather the raw facts (the proposed
|
||||
command line, known MCP pids, the durable marker, the process environment) and
|
||||
pass them in, so one implementation serves prompts, MCP gates and tests.
|
||||
Nothing here kills, spawns or inspects a process, performs I/O, or reads
|
||||
durable state.
|
||||
|
||||
Design rules honoured (from the #630 acceptance criteria):
|
||||
|
||||
* Detect ``pkill -f mcp_server.py``, ``pkill -f gitea_mcp_server``, broad
|
||||
``pkill -f mcp``, ``killall`` equivalents, and ``kill <pid>`` of a known MCP
|
||||
daemon pid.
|
||||
* Detect a pattern broad enough to take unrelated namespaces as collateral
|
||||
damage (``pkill -f python``) even when it never names MCP.
|
||||
* Never flag read-only inspection (``ps aux | grep mcp_server``), a sanctioned
|
||||
client reconnect, or process management unrelated to the daemons. A bare
|
||||
``kill <pid>`` with no MCP linkage is reported as *ambiguous*, never as
|
||||
contamination, so ordinary subprocess work is not false-blocked.
|
||||
* Never accept operator authorization from a tool argument. Authorization is
|
||||
read from the process environment only — which an in-session worker cannot
|
||||
set for an already-running daemon. A self-assertable ``operator_authorized``
|
||||
argument was rejected in the PR #710 review (finding F1) and is not
|
||||
reintroduced here.
|
||||
* Contamination is never clearable by the same worker session; only a
|
||||
reconciler (audit) role may clear it or bypass the gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator
|
||||
|
||||
# Single source of truth for both the redactor and the gated-mutation set: the
|
||||
# #671 guard already owns them, so the two contamination models can never drift
|
||||
# apart on which mutations a contaminated session may still perform.
|
||||
from stable_branch_push_guard import ( # noqa: F401 (CONTAMINATION_GATED_TASKS re-exported)
|
||||
CONTAMINATION_GATED_TASKS,
|
||||
redact_command,
|
||||
)
|
||||
|
||||
CONTAMINATION_KIND = "manual_daemon_kill"
|
||||
|
||||
#: The session killed (or pattern-matched) an MCP daemon process directly.
|
||||
REASON_MANUAL_DAEMON_KILL = "manual_daemon_kill"
|
||||
#: The pattern was broad enough to sweep unrelated MCP namespaces.
|
||||
REASON_BROAD_PROCESS_KILL = "broad_process_kill"
|
||||
|
||||
#: Operator authorization is read from this environment variable ONLY. It is
|
||||
#: set outside the workflow session by the operator who owns host maintenance;
|
||||
#: an in-session worker cannot set it for an already-running daemon. The value
|
||||
#: is an audit reference (ticket, change id, or operator note) and is recorded
|
||||
#: on the marker. Never accept this from a tool argument (#710 finding F1).
|
||||
OPERATOR_AUTHORIZATION_ENV = "GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION"
|
||||
|
||||
REMEDIATION = (
|
||||
"Manual MCP daemon process killing is not a sanctioned workflow recovery. "
|
||||
"Stop, leave the host processes alone, and recover through the client "
|
||||
"reconnect / relaunch path (see docs/mcp-namespace-eof-recovery.md) or an "
|
||||
"operator-owned restart. This session is workflow-contaminated until a "
|
||||
"reconciler audits it; review, merge, close and completion mutations fail "
|
||||
"closed until then."
|
||||
)
|
||||
|
||||
# ── command tokenising ────────────────────────────────────────────────────────
|
||||
|
||||
# Split a compound command line into simple commands on shell separators so
|
||||
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
|
||||
# inspection half never reaches the kill classifier. The background separator
|
||||
# ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was
|
||||
# a single segment whose command position held ``sleep``, so the kill was never
|
||||
# classified (#787).
|
||||
#
|
||||
# Splitting is *quote-aware*, and a regex alternation cannot express that, so
|
||||
# the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator
|
||||
# only separates where it is syntactically active: outside single and double
|
||||
# quotes, and not backslash-escaped. Without that, adding ``&`` made every
|
||||
# benign mention of the canonical kill string classify as a real kill — a commit
|
||||
# message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same
|
||||
# sentence, a ``grep`` for it — and a false contamination marker fails review,
|
||||
# merge, close and completion mutations closed until a reconciler clears it (PR
|
||||
# #789 review finding F1). Quote-awareness is not specific to ``&``: it also
|
||||
# retires the same false-positive class that ``;`` and ``|`` carried before #787.
|
||||
_SEPARATOR_CHARS = frozenset("|&;\n")
|
||||
|
||||
#: Two-character logical separators, consumed whole so ``&&`` and ``||`` are
|
||||
#: never split into single characters leaving a stray operator behind.
|
||||
_LOGICAL_SEPARATORS = ("&&", "||")
|
||||
|
||||
_KILL_VERBS = frozenset({"kill", "pkill", "killall"})
|
||||
|
||||
# Tokens that may legitimately precede the kill verb in command position.
|
||||
_COMMAND_PREFIXES = frozenset({
|
||||
"sudo", "command", "exec", "time", "nohup", "env", "builtin",
|
||||
})
|
||||
|
||||
# Matches the daemon process names: ``mcp_server``/``mcp-server`` (optionally
|
||||
# ``gitea_``-prefixed, optionally ``.py``) or a standalone ``mcp`` token.
|
||||
# ``mcpfoo`` deliberately does not match.
|
||||
_MCP_TARGET_RE = re.compile(
|
||||
r"(?:gitea[_-])?mcp[_-]?server|(?<![\w-])mcp(?![\w-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Patterns broad enough that matching them would kill unrelated MCP namespaces
|
||||
# (and unrelated tooling) as collateral damage.
|
||||
_BROAD_PATTERN_RE = re.compile(
|
||||
r"^(?:python[\d.]*|node|uv|venv|java|ruby|perl|\.|\.\*|\*|%)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ``pkill``/``killall`` flags that consume the following token as their value,
|
||||
# so it is not mistaken for a process pattern.
|
||||
_VALUE_FLAGS = frozenset({
|
||||
"-u", "-U", "-g", "-G", "-P", "-t", "-s", "-F", "-M", "-N", "-r",
|
||||
"--signal", "--uid", "--euid", "--group", "--parent", "--session",
|
||||
"--terminal", "--ns", "--nslist", "--pidfile", "--older",
|
||||
})
|
||||
|
||||
# Sanctioned recovery language — informational only. Its presence never
|
||||
# suppresses a detected kill; a session that describes a reconnect *and* runs
|
||||
# ``pkill`` is still contaminated.
|
||||
_SANCTIONED_RECOVERY_RE = re.compile(
|
||||
r"/mcp\s+reconnect|client\s+reconnect|reconnect\s+the\s+(?:ide|client)|"
|
||||
r"relaunch\s+the\s+(?:ide|client)|ide\s+restart|operator[- ]owned\s+restart",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def _iter_active(text: str) -> Iterator[tuple[int, str]]:
|
||||
"""Yield ``(index, char)`` for every *syntactically active* character.
|
||||
|
||||
Active means outside single and double quotes and not backslash-escaped —
|
||||
the positions where a shell metacharacter actually carries its meaning.
|
||||
Quoted runs, the quote characters themselves, and escaped characters are
|
||||
skipped, so a separator written inside a commit message or a ``grep``
|
||||
pattern is literal text rather than syntax. A backslash escapes nothing
|
||||
inside single quotes, matching POSIX.
|
||||
|
||||
An unterminated quote swallows the rest of the line, exactly as it does for
|
||||
the shell — which would reject such a command as a syntax error rather than
|
||||
run its tail, so nothing executable hides behind it.
|
||||
"""
|
||||
quote: str | None = None
|
||||
index = 0
|
||||
end = len(text)
|
||||
while index < end:
|
||||
char = text[index]
|
||||
if quote == "'":
|
||||
if char == "'":
|
||||
quote = None
|
||||
index += 1
|
||||
elif quote == '"':
|
||||
if char == "\\" and index + 1 < end:
|
||||
index += 2
|
||||
else:
|
||||
if char == '"':
|
||||
quote = None
|
||||
index += 1
|
||||
elif char == "\\" and index + 1 < end:
|
||||
index += 2
|
||||
elif char in ("'", '"'):
|
||||
quote = char
|
||||
index += 1
|
||||
else:
|
||||
yield index, char
|
||||
index += 1
|
||||
|
||||
|
||||
def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool:
|
||||
"""Is the ``&``/``|`` at *index* part of a redirection, not a separator?
|
||||
|
||||
``2>&1`` and ``>&2`` put the character immediately after a redirection
|
||||
operator, and ``&>log`` immediately before one; in neither position does it
|
||||
separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']``
|
||||
(PR #789 review finding F3).
|
||||
"""
|
||||
previous = command[index - 1] if index else ""
|
||||
if previous in ("<", ">") and (index - 1) in active:
|
||||
return True
|
||||
return (
|
||||
command[index] == "&"
|
||||
and command[index + 1:index + 2] == ">"
|
||||
and (index + 1) in active
|
||||
)
|
||||
|
||||
|
||||
def _closes_leading_paren(body: str) -> bool:
|
||||
"""Does *body* end with the active ``)`` matching a stripped leading ``(``?"""
|
||||
if not body.endswith(")"):
|
||||
return False
|
||||
depth = 0
|
||||
for index, char in _iter_active(body):
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
if depth == 0:
|
||||
return index == len(body) - 1
|
||||
depth -= 1
|
||||
return False
|
||||
|
||||
|
||||
def _strip_subshell(segment: str) -> str:
|
||||
"""Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified.
|
||||
|
||||
The parentheses are shell syntax, not part of the simple command, so a
|
||||
wrapped kill otherwise put ``(pkill`` in command position and never
|
||||
reached the kill classifier (#787). Nested wrappers are unwrapped too.
|
||||
|
||||
A trailing ``)`` is removed only when it closes a leading ``(`` this call
|
||||
stripped. Removing one unconditionally mangled balanced command
|
||||
substitution — ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp``
|
||||
(PR #789 review finding F3). An unmatched leading ``(`` is still dropped on
|
||||
its own, because splitting a wrapped compound orphans the opening half.
|
||||
"""
|
||||
stripped = segment.strip()
|
||||
while stripped.startswith("("):
|
||||
body = stripped[1:].strip()
|
||||
if _closes_leading_paren(body):
|
||||
body = body[:-1].strip()
|
||||
stripped = body
|
||||
return stripped
|
||||
|
||||
|
||||
def _split_segments(command: str) -> list[str]:
|
||||
"""Split *command* into simple commands on syntactically active separators."""
|
||||
active = frozenset(index for index, _ in _iter_active(command))
|
||||
segments: list[str] = []
|
||||
start = 0
|
||||
index = 0
|
||||
end = len(command)
|
||||
while index < end:
|
||||
char = command[index]
|
||||
if (
|
||||
char not in _SEPARATOR_CHARS
|
||||
or index not in active
|
||||
or (char in "&|" and _is_redirection(command, index, active))
|
||||
):
|
||||
index += 1
|
||||
continue
|
||||
width = (
|
||||
2
|
||||
if command[index:index + 2] in _LOGICAL_SEPARATORS
|
||||
and (index + 1) in active
|
||||
else 1
|
||||
)
|
||||
segments.append(command[start:index])
|
||||
index += width
|
||||
start = index
|
||||
segments.append(command[start:])
|
||||
return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg]
|
||||
|
||||
|
||||
def is_sanctioned_recovery(text: str | None) -> bool:
|
||||
"""True when *text* describes a sanctioned reconnect/restart path.
|
||||
|
||||
Informational only: this never downgrades a detected process kill.
|
||||
"""
|
||||
return bool(_SANCTIONED_RECOVERY_RE.search(_clean(text)))
|
||||
|
||||
|
||||
# ── kill classification ───────────────────────────────────────────────────────
|
||||
|
||||
def _analyse_kill_segment(
|
||||
segment: str,
|
||||
*,
|
||||
mcp_pids: frozenset[str],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Classify one command segment, or return None when it is not a kill."""
|
||||
tokens = segment.split()
|
||||
idx = 0
|
||||
# Skip env assignments and harmless command prefixes (``sudo pkill ...``).
|
||||
while idx < len(tokens) and (tokens[idx] in _COMMAND_PREFIXES or "=" in tokens[idx]):
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return None
|
||||
|
||||
verb = os.path.basename(tokens[idx]).lower()
|
||||
if verb not in _KILL_VERBS:
|
||||
return None
|
||||
|
||||
operands: list[str] = []
|
||||
skip_next = False
|
||||
for token in tokens[idx + 1:]:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if token.startswith("-"):
|
||||
if token in _VALUE_FLAGS:
|
||||
skip_next = True
|
||||
continue
|
||||
operands.append(token)
|
||||
|
||||
names_mcp = bool(_MCP_TARGET_RE.search(segment))
|
||||
|
||||
def _result(
|
||||
*,
|
||||
reason_class: str | None,
|
||||
contamination: bool,
|
||||
ambiguous: bool,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"verb": verb,
|
||||
"operands": operands,
|
||||
"reason_class": reason_class,
|
||||
"contamination": contamination,
|
||||
"ambiguous": ambiguous,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if verb in {"pkill", "killall"}:
|
||||
if names_mcp:
|
||||
return _result(
|
||||
reason_class=REASON_MANUAL_DAEMON_KILL,
|
||||
contamination=True,
|
||||
ambiguous=False,
|
||||
reason=(
|
||||
f"'{verb}' targets the MCP daemon process pattern; this is "
|
||||
"manual daemon killing, not a sanctioned recovery"
|
||||
),
|
||||
)
|
||||
broad = [op for op in operands if _BROAD_PATTERN_RE.match(op)]
|
||||
if broad:
|
||||
return _result(
|
||||
reason_class=REASON_BROAD_PROCESS_KILL,
|
||||
contamination=True,
|
||||
ambiguous=False,
|
||||
reason=(
|
||||
f"'{verb}' pattern {broad[0]!r} is broad enough to kill "
|
||||
"unrelated MCP namespaces as collateral damage"
|
||||
),
|
||||
)
|
||||
if not operands:
|
||||
return _result(
|
||||
reason_class=None,
|
||||
contamination=False,
|
||||
ambiguous=True,
|
||||
reason=f"'{verb}' with no resolvable pattern; target unknown",
|
||||
)
|
||||
return _result(
|
||||
reason_class=None,
|
||||
contamination=False,
|
||||
ambiguous=False,
|
||||
reason=(
|
||||
f"'{verb}' targets {operands!r}, which does not name an MCP "
|
||||
"daemon or a broad pattern"
|
||||
),
|
||||
)
|
||||
|
||||
# ``kill`` — pid-addressed.
|
||||
pids = [op for op in operands if op.isdigit()]
|
||||
hits = sorted(set(pids) & mcp_pids, key=int)
|
||||
if hits:
|
||||
return _result(
|
||||
reason_class=REASON_MANUAL_DAEMON_KILL,
|
||||
contamination=True,
|
||||
ambiguous=False,
|
||||
reason=(
|
||||
"'kill' targets known MCP daemon pid(s) "
|
||||
f"{', '.join(hits)}; this is manual daemon killing"
|
||||
),
|
||||
)
|
||||
if names_mcp:
|
||||
return _result(
|
||||
reason_class=REASON_MANUAL_DAEMON_KILL,
|
||||
contamination=True,
|
||||
ambiguous=False,
|
||||
reason="'kill' resolves its target from an MCP daemon process lookup",
|
||||
)
|
||||
if not pids:
|
||||
return _result(
|
||||
reason_class=None,
|
||||
contamination=False,
|
||||
ambiguous=True,
|
||||
reason="'kill' with no resolvable numeric pid; target unknown",
|
||||
)
|
||||
return _result(
|
||||
reason_class=None,
|
||||
contamination=False,
|
||||
ambiguous=True,
|
||||
reason=(
|
||||
f"'kill' targets pid(s) {', '.join(pids)}, which are not known MCP "
|
||||
"daemon pids; pass mcp_pids to resolve the ambiguity"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def classify_recovery_command(
|
||||
command: str | None = None,
|
||||
*,
|
||||
mcp_pids: Iterable[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a proposed command for manual MCP daemon kill intent (#630).
|
||||
|
||||
Pure classification; operator authorization is applied separately by
|
||||
:func:`assess_recovery_command`.
|
||||
"""
|
||||
text = _clean(command)
|
||||
pid_set = frozenset(
|
||||
str(pid).strip() for pid in (mcp_pids or []) if str(pid).strip()
|
||||
)
|
||||
|
||||
segments: list[dict[str, Any]] = []
|
||||
for raw_segment in _split_segments(text):
|
||||
analysed = _analyse_kill_segment(raw_segment, mcp_pids=pid_set)
|
||||
if analysed is not None:
|
||||
analysed["segment"] = redact_command(raw_segment)
|
||||
segments.append(analysed)
|
||||
|
||||
contaminating = [seg for seg in segments if seg["contamination"]]
|
||||
return {
|
||||
"command_present": bool(text),
|
||||
"redacted_command": redact_command(text),
|
||||
"process_kill": bool(segments),
|
||||
"contamination": bool(contaminating),
|
||||
"reason_class": contaminating[0]["reason_class"] if contaminating else None,
|
||||
"ambiguous": bool(
|
||||
not contaminating and any(seg["ambiguous"] for seg in segments)
|
||||
),
|
||||
"sanctioned_recovery": is_sanctioned_recovery(text),
|
||||
"segments": segments,
|
||||
"reasons": [seg["reason"] for seg in segments],
|
||||
"known_mcp_pids": sorted(pid_set, key=lambda p: int(p) if p.isdigit() else 0),
|
||||
}
|
||||
|
||||
|
||||
# ── operator authorization ────────────────────────────────────────────────────
|
||||
|
||||
def operator_authorization(env: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""Read operator authorization for host daemon maintenance (#630 non-goal 1).
|
||||
|
||||
Authorization comes from :data:`OPERATOR_AUTHORIZATION_ENV` in the process
|
||||
environment and from nowhere else. A worker session cannot set an
|
||||
environment variable for an already-running daemon, so this cannot be
|
||||
self-asserted the way a tool argument could be (#710 finding F1).
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
reference = _clean(source.get(OPERATOR_AUTHORIZATION_ENV))
|
||||
return {
|
||||
"authorized": bool(reference),
|
||||
"reference": reference or None,
|
||||
"source": OPERATOR_AUTHORIZATION_ENV if reference else None,
|
||||
"self_assertable": False,
|
||||
}
|
||||
|
||||
|
||||
def assess_recovery_command(
|
||||
command: str | None = None,
|
||||
*,
|
||||
mcp_pids: Iterable[Any] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify *command* and apply operator authorization (#630 AC1/AC2)."""
|
||||
classification = classify_recovery_command(command, mcp_pids=mcp_pids)
|
||||
authorization = operator_authorization(env)
|
||||
detected = classification["contamination"]
|
||||
contaminated = detected and not authorization["authorized"]
|
||||
return {
|
||||
"classification": classification,
|
||||
"authorization": authorization,
|
||||
"contaminated": contaminated,
|
||||
"authorized_bypass": bool(detected and authorization["authorized"]),
|
||||
"remediation": REMEDIATION if contaminated else None,
|
||||
}
|
||||
|
||||
|
||||
# ── contamination record + gate ───────────────────────────────────────────────
|
||||
|
||||
def build_contamination_record(
|
||||
*,
|
||||
reason_class: str,
|
||||
command_redacted: str | None = None,
|
||||
session_id: str | None = None,
|
||||
remote: str | None = None,
|
||||
role: str | None = None,
|
||||
detail: str | None = None,
|
||||
authorization_reference: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the durable contamination marker payload (redacted, audit-safe).
|
||||
|
||||
``reason_class`` is :data:`REASON_MANUAL_DAEMON_KILL` or
|
||||
:data:`REASON_BROAD_PROCESS_KILL`. The command is stored already redacted;
|
||||
secrets never persist on the marker.
|
||||
"""
|
||||
return {
|
||||
"kind": CONTAMINATION_KIND,
|
||||
"reason_class": _clean(reason_class) or REASON_MANUAL_DAEMON_KILL,
|
||||
"command_summary": redact_command(command_redacted),
|
||||
"session_id": _clean(session_id) or None,
|
||||
"remote": _clean(remote) or None,
|
||||
"role": _clean(role) or None,
|
||||
"detail": _clean(detail) or None,
|
||||
"authorization_reference": _clean(authorization_reference) or None,
|
||||
"cleared_by_reconciler": False,
|
||||
}
|
||||
|
||||
|
||||
def assess_contamination_gate(
|
||||
marker: dict[str, Any] | None,
|
||||
*,
|
||||
task: str | None,
|
||||
actual_role: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed on gated mutations while a contamination marker is live (#630 AC3).
|
||||
|
||||
* No marker → allowed.
|
||||
* Reconciler (audit) role → allowed (the sanctioned inspect/clear path).
|
||||
* Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked.
|
||||
* Marker present + non-gated task (``comment_issue``, ``lock_issue``) →
|
||||
allowed, so the contaminated worker can still post the durable audit
|
||||
comment and hand off.
|
||||
"""
|
||||
if not marker or marker.get("cleared_by_reconciler"):
|
||||
return {"block": False, "reasons": [], "task": task}
|
||||
|
||||
role = _clean(actual_role).lower()
|
||||
if role == "reconciler":
|
||||
return {
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"task": task,
|
||||
"detail": "reconciler audit path is exempt from the contamination gate",
|
||||
}
|
||||
|
||||
task_name = _clean(task)
|
||||
if task_name and task_name in CONTAMINATION_GATED_TASKS:
|
||||
summary = marker.get("command_summary") or marker.get("detail") or "(no summary)"
|
||||
reason_class = marker.get("reason_class") or REASON_MANUAL_DAEMON_KILL
|
||||
return {
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"session is workflow-contaminated ({reason_class}): {summary}. "
|
||||
f"'{task_name}' is blocked until a reconciler audits and clears "
|
||||
"the contamination. " + REMEDIATION
|
||||
],
|
||||
"task": task_name,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "task": task_name or None}
|
||||
|
||||
|
||||
def format_contamination_gate_error(gate: dict[str, Any]) -> str:
|
||||
"""Single RuntimeError message for MCP mutation gates."""
|
||||
reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"])
|
||||
return f"Runtime-recovery contamination gate (#630): {reasons}"
|
||||
|
||||
|
||||
# ── final-report rules ────────────────────────────────────────────────────────
|
||||
|
||||
# Claims that assert a clean session. While a marker is live these are false and
|
||||
# must be rejected rather than merely downgraded.
|
||||
_CLEAN_CLAIM_RE = re.compile(
|
||||
r"\bclean\s+session\b|\bsession\s+(?:is|was|remains)\s+clean\b|"
|
||||
r"\bno\s+contamination\b|\buncontaminated\b|\bcontamination\s*[:=]\s*none\b|"
|
||||
r"\bworkflow[- ]clean\b|\bno\s+workflow\s+contamination\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Language that actually surfaces the contamination to a reader.
|
||||
_SURFACED_RE = re.compile(
|
||||
r"manual[_ ]daemon[_ ]kill|broad[_ ]process[_ ]kill|daemon\s+process\s+kill|"
|
||||
r"contaminated\s+recovery|runtime[- ]recovery\s+contamination|"
|
||||
r"workflow[- ]contaminated",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def assess_final_report_claim(
|
||||
report_text: str | None,
|
||||
marker: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject clean-session claims while contaminated (#630 scope item 4).
|
||||
|
||||
A live marker imposes two obligations on the final report: it must surface
|
||||
the contaminated recovery explicitly, and it must not claim the session is
|
||||
clean. Either failure blocks.
|
||||
"""
|
||||
if not marker or marker.get("cleared_by_reconciler"):
|
||||
return {
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"contaminated": False,
|
||||
"surfaced": None,
|
||||
"clean_claim": False,
|
||||
}
|
||||
|
||||
text = _clean(report_text)
|
||||
surfaced = bool(_SURFACED_RE.search(text))
|
||||
clean_claim = bool(_CLEAN_CLAIM_RE.search(text))
|
||||
|
||||
reasons: list[str] = []
|
||||
if clean_claim:
|
||||
reasons.append(
|
||||
"final report claims a clean session while a live "
|
||||
f"{marker.get('reason_class') or CONTAMINATION_KIND} contamination "
|
||||
"marker exists; the claim is false and must be removed"
|
||||
)
|
||||
if not surfaced:
|
||||
reasons.append(
|
||||
"final report does not surface the contaminated runtime recovery; "
|
||||
"the report must state that MCP daemon processes were manually "
|
||||
"killed and that the session awaits a reconciler audit"
|
||||
)
|
||||
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"contaminated": True,
|
||||
"surfaced": surfaced,
|
||||
"clean_claim": clean_claim,
|
||||
"reason_class": marker.get("reason_class"),
|
||||
}
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
|
||||
[--source-branch <branch>] [--source-pr <n>] \
|
||||
[--restart-method <text>] [--rollback <text>] \
|
||||
[--health-check-proof <text>] \
|
||||
[--identity-proof <text>] [--profile-proof <text>] \
|
||||
[--workspace-proof <text>] \
|
||||
[--mutation-capability-proof <text>]
|
||||
|
||||
Emit and validate a stable-control-runtime promotion record (#615).
|
||||
|
||||
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
|
||||
anything: promotion itself is an operator action documented in
|
||||
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
|
||||
state, assembles the required record, validates it with
|
||||
stable_control_runtime.assess_promotion_record(), and prints it for the operator
|
||||
to act on and archive.
|
||||
|
||||
Defaults:
|
||||
--root the repository root containing this script
|
||||
--promoted HEAD of that root
|
||||
|
||||
Exit status is non-zero when the assembled record is incomplete, so a promotion
|
||||
cannot be recorded without its proof fields.
|
||||
|
||||
Example:
|
||||
scripts/promote-stable-runtime \
|
||||
--source-branch feat/issue-615-runtime-mode-enforcement \
|
||||
--source-pr 770 \
|
||||
--restart-method "IDE client reconnect (/mcp)" \
|
||||
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
|
||||
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
|
||||
--identity-proof "gitea_whoami per namespace" \
|
||||
--profile-proof "gitea_get_runtime_context per namespace" \
|
||||
--workspace-proof "process root == canonical root; clean" \
|
||||
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT=""
|
||||
PROMOTED=""
|
||||
SOURCE_BRANCH=""
|
||||
SOURCE_PR=""
|
||||
RESTART_METHOD=""
|
||||
ROLLBACK=""
|
||||
HEALTH_PROOF=""
|
||||
IDENTITY_PROOF=""
|
||||
PROFILE_PROOF=""
|
||||
WORKSPACE_PROOF=""
|
||||
CAPABILITY_PROOF=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift 2 ;;
|
||||
--promoted) PROMOTED="${2:-}"; shift 2 ;;
|
||||
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
|
||||
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
|
||||
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
|
||||
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
|
||||
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
|
||||
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
|
||||
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
|
||||
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||
|
||||
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
|
||||
if [[ -z "$PREVIOUS" ]]; then
|
||||
# The runtime the operator is replacing. Best-effort: the commit master
|
||||
# pointed at before the fast-forward, recorded in the reflog.
|
||||
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
|
||||
fi
|
||||
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
|
||||
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
|
||||
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [[ -z "$WORKSPACE_PROOF" ]]; then
|
||||
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
|
||||
fi
|
||||
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
|
||||
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# Stable control runtime promotion record (#615)
|
||||
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
|
||||
|
||||
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
|
||||
promoted_runtime_sha: ${PROMOTED}
|
||||
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
|
||||
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
|
||||
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
|
||||
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
|
||||
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
|
||||
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
|
||||
workspace_proof: ${WORKSPACE_PROOF}
|
||||
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
|
||||
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
|
||||
EOF
|
||||
|
||||
if [[ "$DIRTY" != "0" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
|
||||
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
RECORD_JSON="$(
|
||||
ROOT="$ROOT" \
|
||||
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
|
||||
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
|
||||
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
|
||||
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
|
||||
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
|
||||
ROLLBACK="$ROLLBACK" \
|
||||
"$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
|
||||
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
|
||||
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||
"source_pr": os.environ.get("SOURCE_PR", ""),
|
||||
"restart_method": os.environ.get("RESTART_METHOD", ""),
|
||||
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
|
||||
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
|
||||
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
|
||||
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
|
||||
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
|
||||
"rollback_instructions": os.environ.get("ROLLBACK", ""),
|
||||
}))
|
||||
'
|
||||
)"
|
||||
|
||||
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.environ["ROOT"])
|
||||
import stable_control_runtime as scr
|
||||
|
||||
record = json.loads(os.environ["RECORD_JSON"])
|
||||
assessment = scr.assess_promotion_record(record)
|
||||
print()
|
||||
print("validation:", json.dumps(assessment, indent=2))
|
||||
print()
|
||||
if not assessment["valid"]:
|
||||
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
|
||||
sys.exit(1)
|
||||
print("Promotion record is complete. Archive it on the tracking issue.")
|
||||
'
|
||||
@@ -0,0 +1,907 @@
|
||||
"""Self-propagating canonical handoffs through final controller closure (#626).
|
||||
|
||||
#494-#507 defined the canonical ledger, next-action comments, comment
|
||||
validation, the controller acceptance gate, and the Canonical Thread Handoff
|
||||
(CTH) shape. What none of them enforce is the *chain*: that every actor
|
||||
consumes exactly one canonical handoff, performs exactly one authorized role,
|
||||
records the result durably in Gitea, and emits the next complete handoff until
|
||||
the controller records final closure.
|
||||
|
||||
This module owns that systemic gap:
|
||||
|
||||
* one canonical cross-role handoff schema (:data:`HANDOFF_FIELDS`);
|
||||
* a fail-closed validator that rejects incomplete handoffs;
|
||||
* live-state recovery so a receiving actor never trusts an inherited handoff;
|
||||
* role-limited continuation;
|
||||
* mandatory durable posting into Gitea;
|
||||
* the ``merged-awaiting-controller`` boundary and controller accept/reject
|
||||
continuation;
|
||||
* workflow-failure escalation into separate durable issues, with duplicate
|
||||
handling;
|
||||
* terminal closure that must *not* emit an unnecessary next prompt.
|
||||
|
||||
Everything here is pure assessment: no network calls, no mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
MARKER = "<!-- sph:v1 -->"
|
||||
HANDOFF_HEADING = "Canonical Handoff"
|
||||
|
||||
#: Canonical workflow states a handoff may declare.
|
||||
WORKFLOW_STATES: tuple[str, ...] = (
|
||||
"needs-author",
|
||||
"needs-review",
|
||||
"approved-awaiting-merge",
|
||||
"merged-awaiting-controller",
|
||||
"blocked",
|
||||
"complete",
|
||||
)
|
||||
|
||||
TERMINAL_STATES = frozenset({"complete"})
|
||||
|
||||
#: The single role authorized to act on each workflow state.
|
||||
NEXT_ACTOR_BY_STATE: dict[str, str] = {
|
||||
"needs-author": "author",
|
||||
"needs-review": "reviewer",
|
||||
"approved-awaiting-merge": "merger",
|
||||
"merged-awaiting-controller": "controller",
|
||||
"blocked": "operator",
|
||||
"complete": "none",
|
||||
}
|
||||
|
||||
WORKFLOW_ROLES = frozenset(
|
||||
{"author", "reviewer", "merger", "controller", "operator", "reconciler"}
|
||||
)
|
||||
|
||||
#: What each receiving role is authorized to do when it consumes a handoff.
|
||||
ROLE_ALLOWED_ACTIONS: dict[str, tuple[str, ...]] = {
|
||||
"author": ("implement", "commit", "push", "create_pr", "comment"),
|
||||
"reviewer": ("review", "approve", "request_changes", "comment"),
|
||||
"merger": ("verify_approval_parity", "merge", "comment"),
|
||||
"controller": ("accept", "reject", "reopen", "close_issue", "comment"),
|
||||
"operator": ("repair_infrastructure", "comment"),
|
||||
"reconciler": ("close_superseded_pr", "cleanup_branch", "comment"),
|
||||
}
|
||||
|
||||
ROLE_FORBIDDEN_ACTIONS: dict[str, tuple[str, ...]] = {
|
||||
"author": ("approve", "request_changes", "merge", "close_issue"),
|
||||
"reviewer": ("merge", "commit", "push", "create_pr"),
|
||||
"merger": ("approve", "commit", "push", "create_pr"),
|
||||
"controller": ("approve", "merge", "commit", "push"),
|
||||
"operator": ("approve", "merge", "close_issue"),
|
||||
"reconciler": ("approve", "merge", "commit", "push", "create_pr"),
|
||||
}
|
||||
|
||||
#: Ordered canonical handoff fields. Every one of them is required; the
|
||||
#: fields in :data:`NONE_ALLOWED_FIELDS` may legitimately carry ``none``.
|
||||
HANDOFF_FIELDS: tuple[str, ...] = (
|
||||
"REPOSITORY",
|
||||
"ISSUE",
|
||||
"PR",
|
||||
"WORKFLOW_STATE",
|
||||
"HEAD_SHA",
|
||||
"BASE_BRANCH",
|
||||
"BASE_OR_MERGE_SHA",
|
||||
"ACTING_ROLE",
|
||||
"ACTING_IDENTITY",
|
||||
"COMPLETED_ACTIONS",
|
||||
"VALIDATION_EVIDENCE",
|
||||
"MUTATION_LEDGER",
|
||||
"BLOCKERS",
|
||||
"NEXT_ACTOR",
|
||||
"NEXT_ACTION",
|
||||
"PROHIBITED_ACTIONS",
|
||||
"NEXT_PROMPT",
|
||||
"WORKFLOW_FAILURE_ISSUES",
|
||||
"LAST_UPDATED",
|
||||
)
|
||||
|
||||
NONE_ALLOWED_FIELDS = frozenset(
|
||||
{
|
||||
"PR",
|
||||
"HEAD_SHA",
|
||||
"BASE_OR_MERGE_SHA",
|
||||
"BLOCKERS",
|
||||
"WORKFLOW_FAILURE_ISSUES",
|
||||
"NEXT_PROMPT",
|
||||
"NEXT_ACTION",
|
||||
}
|
||||
)
|
||||
|
||||
#: States where no PR or head SHA exists yet, so ``none`` is legitimate.
|
||||
_PRE_PR_STATES = frozenset({"needs-author", "blocked"})
|
||||
|
||||
_PLACEHOLDERS = frozenset({"", "none", "n/a", "na", "tbd", "todo", "unknown", "?"})
|
||||
|
||||
#: A next prompt short enough to be a stub cannot be "ready to run".
|
||||
MIN_NEXT_PROMPT_CHARS = 40
|
||||
|
||||
_FIELD_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE)
|
||||
_HEADING_RE = re.compile(r"^##\s*Canonical Handoff\s*$", re.IGNORECASE | re.MULTILINE)
|
||||
_EXTERNAL_CHAT_RE = re.compile(
|
||||
r"\b(?:previous chat|prior conversation|earlier conversation|see (?:the )?chat|"
|
||||
r"chat history|paste (?:this )?(?:from|into) chatgpt|ask the operator to paste)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
LIVE_DETECTION_KINDS: tuple[str, ...] = (
|
||||
"changed_pr_head",
|
||||
"stale_approval",
|
||||
"issue_closed",
|
||||
"issue_reopened",
|
||||
"pr_merged",
|
||||
"pr_closed_unmerged",
|
||||
"stale_lease",
|
||||
"foreign_lease",
|
||||
"missing_worktree",
|
||||
"dirty_worktree",
|
||||
"namespace_mismatch",
|
||||
"stale_runtime",
|
||||
"changed_base",
|
||||
"conflicting_canonical_comments",
|
||||
)
|
||||
|
||||
CONTROLLER_DECISIONS = frozenset(
|
||||
{
|
||||
"accept",
|
||||
"request_tests",
|
||||
"request_proof",
|
||||
"request_corrections",
|
||||
"reopen",
|
||||
"return_to_actor",
|
||||
}
|
||||
)
|
||||
|
||||
CONTROLLER_CLOSURE_PROOF_FIELDS = (
|
||||
"acceptance_criteria_satisfied",
|
||||
"cleanup_complete",
|
||||
"canonical_final_state_posted",
|
||||
"issue_closed_through_workflow",
|
||||
)
|
||||
|
||||
WORKFLOW_FAILURE_FIELDS = (
|
||||
"classification",
|
||||
"linked_issue",
|
||||
"temporary_impact",
|
||||
"next_valid_actor",
|
||||
"recovery_prompt",
|
||||
)
|
||||
|
||||
|
||||
def _is_placeholder(value: Any) -> bool:
|
||||
return str(value or "").strip().lower() in _PLACEHOLDERS
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rendering / parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_self_propagating_handoff(**values: Any) -> str:
|
||||
"""Render a canonical cross-role handoff block.
|
||||
|
||||
Raises ``ValueError`` for an unknown workflow state so a malformed handoff
|
||||
can never be produced by the sanctioned renderer.
|
||||
"""
|
||||
state = str(values.get("WORKFLOW_STATE", values.get("workflow_state", ""))).strip()
|
||||
if state not in WORKFLOW_STATES:
|
||||
raise ValueError(
|
||||
f"unknown workflow state '{state}'; expected one of {list(WORKFLOW_STATES)}"
|
||||
)
|
||||
lines = [MARKER, f"## {HANDOFF_HEADING}", "", "```text"]
|
||||
for name in HANDOFF_FIELDS:
|
||||
raw = values.get(name, values.get(name.lower()))
|
||||
lines.append(f"{name}: {_clean(raw)}")
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_self_propagating_handoff(text: str) -> dict[str, str] | None:
|
||||
"""Parse a canonical handoff block, or ``None`` when absent."""
|
||||
body = text or ""
|
||||
if MARKER not in body and not _HEADING_RE.search(body):
|
||||
return None
|
||||
fields = {
|
||||
match.group(1): match.group(2).strip()
|
||||
for match in _FIELD_LINE_RE.finditer(body)
|
||||
}
|
||||
if not fields:
|
||||
return None
|
||||
return fields
|
||||
|
||||
|
||||
def handoff_present(text: str) -> bool:
|
||||
"""Whether *text* carries a canonical handoff block at all."""
|
||||
return parse_self_propagating_handoff(text) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handoff validation (AC: a validator rejects incomplete handoffs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_self_propagating_handoff(text: str) -> dict[str, Any]:
|
||||
"""Fail closed unless *text* carries one complete canonical handoff."""
|
||||
fields = parse_self_propagating_handoff(text)
|
||||
if fields is None:
|
||||
return {
|
||||
"valid": False,
|
||||
"block": True,
|
||||
"present": False,
|
||||
"fields": {},
|
||||
"missing_fields": list(HANDOFF_FIELDS),
|
||||
"workflow_state": None,
|
||||
"next_actor": None,
|
||||
"terminal": False,
|
||||
"reasons": ["report or comment carries no canonical handoff block"],
|
||||
"safe_next_action": (
|
||||
"add a canonical handoff block with all "
|
||||
f"{len(HANDOFF_FIELDS)} fields before posting"
|
||||
),
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
terminal = state in TERMINAL_STATES
|
||||
|
||||
if state not in WORKFLOW_STATES:
|
||||
reasons.append(
|
||||
f"unknown WORKFLOW_STATE '{state or 'missing'}'; "
|
||||
f"expected one of {list(WORKFLOW_STATES)}"
|
||||
)
|
||||
|
||||
missing = [name for name in HANDOFF_FIELDS if name not in fields]
|
||||
reasons.extend(f"handoff missing field: {name}" for name in missing)
|
||||
|
||||
for name in HANDOFF_FIELDS:
|
||||
if name in missing:
|
||||
continue
|
||||
value = fields.get(name, "")
|
||||
if not _is_placeholder(value):
|
||||
continue
|
||||
if name in NONE_ALLOWED_FIELDS:
|
||||
continue
|
||||
# A terminated chain names no next actor by design.
|
||||
if name == "NEXT_ACTOR" and terminal:
|
||||
continue
|
||||
reasons.append(f"handoff field {name} must be concrete, got '{value or ''}'")
|
||||
|
||||
if state and state not in _PRE_PR_STATES and state in WORKFLOW_STATES:
|
||||
for name in ("PR", "HEAD_SHA"):
|
||||
if name not in missing and _is_placeholder(fields.get(name)):
|
||||
reasons.append(
|
||||
f"handoff field {name} must be concrete in state '{state}'"
|
||||
)
|
||||
|
||||
if state == "blocked" and _is_placeholder(fields.get("BLOCKERS")):
|
||||
reasons.append("state 'blocked' requires a concrete BLOCKERS entry")
|
||||
|
||||
declared_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
||||
expected_actor = NEXT_ACTOR_BY_STATE.get(state)
|
||||
if expected_actor and declared_actor != expected_actor:
|
||||
reasons.append(
|
||||
f"NEXT_ACTOR '{declared_actor or 'missing'}' does not match state "
|
||||
f"'{state}', which authorizes '{expected_actor}'"
|
||||
)
|
||||
|
||||
next_prompt = (fields.get("NEXT_PROMPT") or "").strip()
|
||||
next_action = (fields.get("NEXT_ACTION") or "").strip()
|
||||
if terminal:
|
||||
# A completed workflow terminates; it must not manufacture more work.
|
||||
if not _is_placeholder(next_prompt):
|
||||
reasons.append(
|
||||
"terminal state 'complete' must not carry a NEXT_PROMPT; "
|
||||
"the chain ends at controller closure"
|
||||
)
|
||||
if not _is_placeholder(next_action):
|
||||
reasons.append(
|
||||
"terminal state 'complete' must not carry a NEXT_ACTION"
|
||||
)
|
||||
else:
|
||||
if _is_placeholder(next_prompt):
|
||||
reasons.append(
|
||||
"non-terminal handoff requires a complete ready-to-run NEXT_PROMPT"
|
||||
)
|
||||
elif len(next_prompt) < MIN_NEXT_PROMPT_CHARS:
|
||||
reasons.append(
|
||||
"NEXT_PROMPT is too short to be ready-to-run "
|
||||
f"({len(next_prompt)} < {MIN_NEXT_PROMPT_CHARS} characters)"
|
||||
)
|
||||
if _is_placeholder(next_action):
|
||||
reasons.append("non-terminal handoff requires a concrete NEXT_ACTION")
|
||||
|
||||
acting_role = (fields.get("ACTING_ROLE") or "").strip().lower()
|
||||
if acting_role and acting_role not in WORKFLOW_ROLES:
|
||||
reasons.append(
|
||||
f"unknown ACTING_ROLE '{acting_role}'; expected one of "
|
||||
f"{sorted(WORKFLOW_ROLES)}"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"valid": not block,
|
||||
"block": block,
|
||||
"present": True,
|
||||
"fields": fields,
|
||||
"missing_fields": missing,
|
||||
"workflow_state": state or None,
|
||||
"next_actor": declared_actor or None,
|
||||
"terminal": terminal,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"complete every canonical handoff field before posting"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_thread_recoverability(text: str) -> dict[str, Any]:
|
||||
"""The next actor must recover from the thread alone — never outside chat."""
|
||||
assessment = assess_self_propagating_handoff(text)
|
||||
if assessment["block"]:
|
||||
return {
|
||||
"recoverable": False,
|
||||
"block": True,
|
||||
"reasons": assessment["reasons"],
|
||||
"safe_next_action": assessment["safe_next_action"],
|
||||
}
|
||||
|
||||
fields = assessment["fields"]
|
||||
reasons: list[str] = []
|
||||
if assessment["terminal"]:
|
||||
return {
|
||||
"recoverable": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
prompt = fields.get("NEXT_PROMPT", "")
|
||||
repository = fields.get("REPOSITORY", "").strip()
|
||||
issue = fields.get("ISSUE", "").strip().lstrip("#")
|
||||
|
||||
if repository and repository.lower() not in prompt.lower():
|
||||
reasons.append("NEXT_PROMPT must name the repository it applies to")
|
||||
if issue and issue not in prompt:
|
||||
reasons.append(f"NEXT_PROMPT must name issue {issue}")
|
||||
if _EXTERNAL_CHAT_RE.search(prompt):
|
||||
reasons.append(
|
||||
"NEXT_PROMPT must not depend on outside chat history; the issue or "
|
||||
"PR thread, workflow docs, and live repository state must suffice"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"recoverable": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"rewrite NEXT_PROMPT so it is self-contained" if block else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# live-state recovery (AC: head changes invalidate stale review/merge handoffs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detection(kind: str, detail: str) -> dict[str, str]:
|
||||
return {"kind": kind, "detail": detail}
|
||||
|
||||
|
||||
def assess_handoff_live_state(
|
||||
*,
|
||||
handoff: str | Mapping[str, str],
|
||||
live: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Re-derive workflow truth from live state instead of trusting *handoff*.
|
||||
|
||||
*live* carries observed facts; absent keys are simply not checked, but any
|
||||
fact that contradicts the inherited handoff fails closed.
|
||||
"""
|
||||
if isinstance(handoff, Mapping):
|
||||
fields = dict(handoff)
|
||||
else:
|
||||
parsed = parse_self_propagating_handoff(handoff or "")
|
||||
if parsed is None:
|
||||
return {
|
||||
"block": True,
|
||||
"detections": [_detection("missing_handoff", "no canonical handoff")],
|
||||
"kinds": ["missing_handoff"],
|
||||
"reasons": ["no canonical handoff to reconcile against live state"],
|
||||
"recovered_state": None,
|
||||
"safe_next_action": "post a canonical handoff before continuing",
|
||||
}
|
||||
fields = parsed
|
||||
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
next_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
||||
detections: list[dict[str, str]] = []
|
||||
recovered_state: str | None = None
|
||||
|
||||
handoff_head = (fields.get("HEAD_SHA") or "").strip()
|
||||
live_head = str(live.get("pr_head_sha") or "").strip()
|
||||
head_changed = bool(
|
||||
live_head and handoff_head and not _is_placeholder(handoff_head)
|
||||
and live_head != handoff_head
|
||||
)
|
||||
if head_changed:
|
||||
detections.append(
|
||||
_detection(
|
||||
"changed_pr_head",
|
||||
f"handoff pinned {handoff_head}, live head is {live_head}",
|
||||
)
|
||||
)
|
||||
if next_actor in {"reviewer", "merger"}:
|
||||
recovered_state = "needs-review"
|
||||
|
||||
approved_head = str(live.get("approved_head_sha") or "").strip()
|
||||
if approved_head and live_head and approved_head != live_head:
|
||||
detections.append(
|
||||
_detection(
|
||||
"stale_approval",
|
||||
f"approval recorded at {approved_head}, live head is {live_head}",
|
||||
)
|
||||
)
|
||||
if next_actor == "merger":
|
||||
recovered_state = "needs-review"
|
||||
|
||||
issue_state = str(live.get("issue_state") or "").strip().lower()
|
||||
if issue_state == "closed" and state not in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("issue_closed", "linked issue is closed but handoff is not complete")
|
||||
)
|
||||
if issue_state == "open" and state in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("issue_reopened", "handoff claims complete but the issue is open")
|
||||
)
|
||||
recovered_state = "needs-author"
|
||||
|
||||
pr_state = str(live.get("pr_state") or "").strip().lower()
|
||||
if pr_state == "merged" and state in {
|
||||
"needs-author",
|
||||
"needs-review",
|
||||
"approved-awaiting-merge",
|
||||
}:
|
||||
detections.append(
|
||||
_detection("pr_merged", "PR is already merged; controller boundary applies")
|
||||
)
|
||||
recovered_state = "merged-awaiting-controller"
|
||||
if pr_state == "closed" and state not in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("pr_closed_unmerged", "PR is closed without merge")
|
||||
)
|
||||
|
||||
lease = live.get("lease") or {}
|
||||
if isinstance(lease, Mapping) and lease:
|
||||
lease_status = str(lease.get("status") or "").strip().lower()
|
||||
if lease_status and lease_status != "active":
|
||||
detections.append(
|
||||
_detection("stale_lease", f"lease status is '{lease_status}'")
|
||||
)
|
||||
lease_session = str(lease.get("session_id") or "").strip()
|
||||
actor_session = str(live.get("actor_session_id") or "").strip()
|
||||
if lease_session and actor_session and lease_session != actor_session:
|
||||
detections.append(
|
||||
_detection(
|
||||
"foreign_lease",
|
||||
"lease is owned by another session; never adopt it implicitly",
|
||||
)
|
||||
)
|
||||
|
||||
worktree = live.get("worktree") or {}
|
||||
if isinstance(worktree, Mapping) and worktree:
|
||||
if worktree.get("present") is False:
|
||||
detections.append(_detection("missing_worktree", "bound worktree is absent"))
|
||||
if worktree.get("dirty") is True:
|
||||
detections.append(
|
||||
_detection("dirty_worktree", "bound worktree carries uncommitted changes")
|
||||
)
|
||||
|
||||
namespace_role = str(live.get("namespace_role") or "").strip().lower()
|
||||
if namespace_role and next_actor and next_actor != "none":
|
||||
if namespace_role != next_actor:
|
||||
detections.append(
|
||||
_detection(
|
||||
"namespace_mismatch",
|
||||
f"live namespace role '{namespace_role}' cannot act as '{next_actor}'",
|
||||
)
|
||||
)
|
||||
|
||||
if live.get("runtime_stale") is True:
|
||||
detections.append(
|
||||
_detection("stale_runtime", "serving runtime is stale; reconnect required")
|
||||
)
|
||||
|
||||
handoff_base = (fields.get("BASE_BRANCH") or "").strip()
|
||||
live_base = str(live.get("base_branch") or "").strip()
|
||||
if handoff_base and live_base and not _is_placeholder(handoff_base):
|
||||
if handoff_base != live_base:
|
||||
detections.append(
|
||||
_detection(
|
||||
"changed_base",
|
||||
f"handoff base '{handoff_base}' but live base '{live_base}'",
|
||||
)
|
||||
)
|
||||
|
||||
if live.get("conflicting_canonical_comments") is True:
|
||||
detections.append(
|
||||
_detection(
|
||||
"conflicting_canonical_comments",
|
||||
"thread carries contradictory canonical comments",
|
||||
)
|
||||
)
|
||||
|
||||
kinds = [item["kind"] for item in detections]
|
||||
reasons = [f"{item['kind']}: {item['detail']}" for item in detections]
|
||||
block = bool(detections)
|
||||
return {
|
||||
"block": block,
|
||||
"detections": detections,
|
||||
"kinds": kinds,
|
||||
"reasons": reasons,
|
||||
"recovered_state": recovered_state,
|
||||
"safe_next_action": (
|
||||
"post a corrected canonical handoff for the recovered live state "
|
||||
"before acting"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# role-limited continuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_role_continuation(
|
||||
*,
|
||||
handoff: str | Mapping[str, str],
|
||||
actor_role: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Only the role the current state authorizes may continue the chain."""
|
||||
if isinstance(handoff, Mapping):
|
||||
fields = dict(handoff)
|
||||
else:
|
||||
fields = parse_self_propagating_handoff(handoff or "") or {}
|
||||
|
||||
role = (actor_role or "").strip().lower()
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
expected = NEXT_ACTOR_BY_STATE.get(state)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not fields:
|
||||
reasons.append("no canonical handoff to continue from")
|
||||
if role not in WORKFLOW_ROLES:
|
||||
reasons.append(f"unknown actor role '{actor_role}'")
|
||||
if expected is None and fields:
|
||||
reasons.append(f"unknown workflow state '{state}'")
|
||||
elif expected == "none":
|
||||
reasons.append(
|
||||
"workflow state 'complete' is terminal; no further role may continue"
|
||||
)
|
||||
elif expected and role != expected:
|
||||
reasons.append(
|
||||
f"state '{state}' authorizes '{expected}', not '{role}'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"expected_actor": expected,
|
||||
"actor_role": role,
|
||||
"allowed_actions": () if block else ROLE_ALLOWED_ACTIONS.get(role, ()),
|
||||
"forbidden_actions": ROLE_FORBIDDEN_ACTIONS.get(role, ()),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
f"hand off to '{expected}'" if block and expected else
|
||||
"stop; the workflow is complete" if expected == "none" else
|
||||
"proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# durable posting (AC: a chat-only report is never sufficient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_durable_state_update(
|
||||
*,
|
||||
handoff_text: str,
|
||||
posted_comment_id: Any = None,
|
||||
canonical_state_posted: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""A successful actor session must leave the handoff in Gitea, not chat."""
|
||||
reasons: list[str] = []
|
||||
assessment = assess_self_propagating_handoff(handoff_text)
|
||||
if assessment["block"]:
|
||||
reasons.extend(assessment["reasons"])
|
||||
if not posted_comment_id:
|
||||
reasons.append(
|
||||
"canonical handoff was not posted to Gitea; a chat-only report is "
|
||||
"not durable workflow state"
|
||||
)
|
||||
if not canonical_state_posted:
|
||||
reasons.append(
|
||||
"canonical issue/PR state and thread ledger were not updated"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"durable": not block,
|
||||
"block": block,
|
||||
"posted_comment_id": posted_comment_id,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"post the canonical handoff and state update to Gitea before "
|
||||
"ending the session"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge -> controller boundary and controller continuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_merge_completion_transition(
|
||||
*,
|
||||
merge_succeeded: bool,
|
||||
controller_auto_accept: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""A merged PR is not accepted work until the controller says so."""
|
||||
if not merge_succeeded:
|
||||
return {
|
||||
"next_state": "approved-awaiting-merge",
|
||||
"next_actor": "merger",
|
||||
"next_prompt_required": True,
|
||||
"reasons": ["merge did not succeed; the merger retains the work item"],
|
||||
}
|
||||
if controller_auto_accept:
|
||||
return {
|
||||
"next_state": "complete",
|
||||
"next_actor": "none",
|
||||
"next_prompt_required": False,
|
||||
"reasons": ["configured workflow authorizes automatic acceptance on merge"],
|
||||
}
|
||||
return {
|
||||
"next_state": "merged-awaiting-controller",
|
||||
"next_actor": "controller",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
"merge succeeded; acceptance requires the authorized controller"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_controller_decision(
|
||||
*,
|
||||
decision: str,
|
||||
closure_proof: Mapping[str, Any] | None = None,
|
||||
return_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Controller acceptance or rejection produces the next or final state."""
|
||||
normalized = (decision or "").strip().lower()
|
||||
if normalized not in CONTROLLER_DECISIONS:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": None,
|
||||
"next_actor": None,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
f"unknown controller decision '{decision}'; expected one of "
|
||||
f"{sorted(CONTROLLER_DECISIONS)}"
|
||||
],
|
||||
"safe_next_action": "record a supported controller decision",
|
||||
}
|
||||
|
||||
if normalized == "accept":
|
||||
proof = dict(closure_proof or {})
|
||||
missing = [
|
||||
name
|
||||
for name in CONTROLLER_CLOSURE_PROOF_FIELDS
|
||||
if proof.get(name) is not True
|
||||
]
|
||||
if missing:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": "merged-awaiting-controller",
|
||||
"next_actor": "controller",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
"controller acceptance missing closure proof: " + ", ".join(missing)
|
||||
],
|
||||
"safe_next_action": (
|
||||
"satisfy and record every closure proof field before closing"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": "complete",
|
||||
"next_actor": "none",
|
||||
"next_prompt_required": False,
|
||||
"reasons": ["controller accepted; workflow chain terminates"],
|
||||
"safe_next_action": "post the final canonical state and stop",
|
||||
}
|
||||
|
||||
if normalized == "return_to_actor":
|
||||
target = (return_to or "").strip().lower()
|
||||
state_by_actor = {
|
||||
"author": "needs-author",
|
||||
"reviewer": "needs-review",
|
||||
"merger": "approved-awaiting-merge",
|
||||
}
|
||||
if target not in state_by_actor:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": None,
|
||||
"next_actor": None,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
f"return_to_actor requires a target in {sorted(state_by_actor)}"
|
||||
],
|
||||
"safe_next_action": "name the actor the work returns to",
|
||||
}
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": state_by_actor[target],
|
||||
"next_actor": target,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [f"controller returned the work item to '{target}'"],
|
||||
"safe_next_action": f"post a complete handoff for '{target}'",
|
||||
}
|
||||
|
||||
# request_tests / request_proof / request_corrections / reopen
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": "needs-author",
|
||||
"next_actor": "author",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [f"controller decision '{normalized}' returns the work to the author"],
|
||||
"safe_next_action": "post a complete author handoff describing what is required",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflow-failure escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_workflow_failure_escalation(
|
||||
*,
|
||||
failures: Sequence[Mapping[str, Any]] | None,
|
||||
active_issue_number: int | str | None,
|
||||
existing_failure_issues: Iterable[Mapping[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Tooling defects hit while working an issue become separate durable work."""
|
||||
entries = list(failures or [])
|
||||
known = {
|
||||
str((item.get("signature") or "")).strip().lower(): item.get("number")
|
||||
for item in (existing_failure_issues or [])
|
||||
if str((item.get("signature") or "")).strip()
|
||||
}
|
||||
active = str(active_issue_number or "").strip().lstrip("#")
|
||||
|
||||
reasons: list[str] = []
|
||||
reused: list[dict[str, Any]] = []
|
||||
seen_signatures: dict[str, str] = {}
|
||||
|
||||
for index, failure in enumerate(entries):
|
||||
label = str(failure.get("signature") or f"failure[{index}]")
|
||||
missing = [
|
||||
name
|
||||
for name in WORKFLOW_FAILURE_FIELDS
|
||||
if _is_placeholder(failure.get(name))
|
||||
]
|
||||
if missing:
|
||||
reasons.append(
|
||||
f"{label}: workflow failure missing " + ", ".join(missing)
|
||||
)
|
||||
|
||||
linked = str(failure.get("linked_issue") or "").strip().lstrip("#")
|
||||
if linked and active and linked == active:
|
||||
reasons.append(
|
||||
f"{label}: workflow defects must not be folded into the active "
|
||||
f"work item #{active}; file a separate durable issue"
|
||||
)
|
||||
|
||||
signature = str(failure.get("signature") or "").strip().lower()
|
||||
if not signature:
|
||||
continue
|
||||
if signature in known:
|
||||
expected = str(known[signature] or "").strip().lstrip("#")
|
||||
if linked and expected and linked != expected:
|
||||
reasons.append(
|
||||
f"{label}: duplicate workflow-failure issue #{linked}; "
|
||||
f"reuse the existing issue #{expected}"
|
||||
)
|
||||
else:
|
||||
reused.append({"signature": signature, "issue": expected})
|
||||
if signature in seen_signatures:
|
||||
reasons.append(
|
||||
f"{label}: duplicate workflow-failure signature reported twice "
|
||||
"in one session"
|
||||
)
|
||||
else:
|
||||
seen_signatures[signature] = linked
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"escalated": not block,
|
||||
"block": block,
|
||||
"failure_count": len(entries),
|
||||
"reused_issues": reused,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"file or reference one durable issue per distinct workflow failure"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# final-report integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_final_report_self_propagating_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""#626 gate for final reports.
|
||||
|
||||
Applicability mirrors the #495 canonical-state gate: once a report adopts
|
||||
the protocol — by carrying the marker, the ``Canonical Handoff`` heading,
|
||||
or a ``WORKFLOW_STATE`` line — the full schema is enforced. Reports that
|
||||
predate the protocol are untouched here; the workflow schemas require the
|
||||
block going forward.
|
||||
"""
|
||||
text = report_text or ""
|
||||
applicable = (
|
||||
MARKER in text
|
||||
or bool(_HEADING_RE.search(text))
|
||||
or bool(re.search(r"^WORKFLOW_STATE\s*:", text, re.MULTILINE))
|
||||
)
|
||||
if not applicable:
|
||||
return {
|
||||
"applicable": False,
|
||||
"valid": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
assessment = assess_self_propagating_handoff(text)
|
||||
recoverability = assess_thread_recoverability(text)
|
||||
reasons = list(assessment["reasons"])
|
||||
if not assessment["block"]:
|
||||
reasons.extend(recoverability.get("reasons") or [])
|
||||
block = bool(assessment["block"] or recoverability.get("block"))
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": not block,
|
||||
"block": block,
|
||||
"workflow_state": assessment.get("workflow_state"),
|
||||
"next_actor": assessment.get("next_actor"),
|
||||
"terminal": assessment.get("terminal"),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
assessment["safe_next_action"]
|
||||
if assessment["block"]
|
||||
else recoverability.get("safe_next_action", "proceed")
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Sentry → Gitea incident bridge (#607).
|
||||
|
||||
Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them
|
||||
into #612 observations, and reconciles them into durable Gitea issues.
|
||||
|
||||
Hard rules (inherited from #612 and restated here):
|
||||
* Gitea owns workflow state; Sentry is observability **input only**.
|
||||
* Raw Sentry incidents are never assignable control-plane ``work_items``.
|
||||
* Dedupe/link/create is delegated to :mod:`incident_bridge` — this module
|
||||
never invents a second linking substrate.
|
||||
* Tokens, DSNs, and raw headers never appear in returns, bodies, or logs.
|
||||
* The watchdog defaults to dry-run; ``apply`` is explicit.
|
||||
|
||||
Network access is injected as ``http_fn`` so the whole surface is testable
|
||||
without a live Sentry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import incident_bridge
|
||||
import sentry_observability
|
||||
|
||||
PROVIDER = "sentry"
|
||||
|
||||
ENV_BASE_URL = "SENTRY_BASE_URL"
|
||||
ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN"
|
||||
ENV_ORG = "SENTRY_ORG"
|
||||
ENV_PROJECT = "SENTRY_PROJECT"
|
||||
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||
ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED"
|
||||
ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE"
|
||||
ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK"
|
||||
|
||||
DEFAULT_BASE_URL = "https://sentry.prgs.cc"
|
||||
DEFAULT_LOOKBACK = "24h"
|
||||
DEFAULT_MIN_EVENTS = 2
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
DEFAULT_PAGE_SIZE = 25
|
||||
MAX_PAGE_SIZE = 100
|
||||
DEFAULT_MAX_PAGES = 10
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
# Absolute local paths embedded in free text. Mirrors the shape matched by
|
||||
# sentry_observability's internal path detector; each hit is replaced by the
|
||||
# coarse category from sentry_observability.sanitize_path.
|
||||
_ABS_PATH_RE = re.compile(
|
||||
r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*"
|
||||
)
|
||||
_LOOKBACK_RE = re.compile(r"^\d+[mhd]$")
|
||||
_CURSOR_RE = re.compile(r'cursor="([^"]+)"')
|
||||
_RESULTS_RE = re.compile(r'results="([^"]+)"')
|
||||
_REL_RE = re.compile(r'rel="([^"]+)"')
|
||||
|
||||
# Error kinds surfaced to callers (stable strings; safe to branch on).
|
||||
ERROR_NOT_CONFIGURED = "not_configured"
|
||||
ERROR_MISSING_TOKEN = "missing_token"
|
||||
ERROR_UNAVAILABLE = "sentry_unavailable"
|
||||
ERROR_HTTP = "sentry_http_error"
|
||||
ERROR_INVALID_RESPONSE = "invalid_response"
|
||||
ERROR_BRIDGE_DISABLED = "bridge_disabled"
|
||||
|
||||
# Watchdog per-issue dispositions.
|
||||
ACTION_RECONCILED = "reconciled"
|
||||
ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold"
|
||||
ACTION_SKIPPED_STATUS = "skipped_not_unresolved"
|
||||
ACTION_FAILED = "failed"
|
||||
|
||||
|
||||
class SentryApiError(RuntimeError):
|
||||
"""Sentry read failure with a stable, redacted classification."""
|
||||
|
||||
def __init__(self, message: str, *, kind: str, status: int | None = None):
|
||||
super().__init__(incident_bridge.redact_text(message))
|
||||
self.kind = kind
|
||||
self.status = status
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"error_kind": self.kind,
|
||||
"status": self.status,
|
||||
"message": str(self),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentryBridgeConfig:
|
||||
"""Resolved bridge configuration. Never carries the auth token."""
|
||||
|
||||
base_url: str
|
||||
org: str
|
||||
project: str
|
||||
environment: str | None = None
|
||||
lookback: str = DEFAULT_LOOKBACK
|
||||
min_events_for_issue: int = DEFAULT_MIN_EVENTS
|
||||
bridge_enabled: bool = False
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
|
||||
def issues_path(self) -> str:
|
||||
return f"/api/0/projects/{self.org}/{self.project}/issues/"
|
||||
|
||||
def issue_events_path(self, issue_id: str) -> str:
|
||||
return f"/api/0/issues/{issue_id}/events/"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Safe projection. The auth token is never included by construction."""
|
||||
return {
|
||||
"base_url": self.base_url,
|
||||
"org": self.org,
|
||||
"project": self.project,
|
||||
"environment": self.environment,
|
||||
"lookback": self.lookback,
|
||||
"min_events_for_issue": self.min_events_for_issue,
|
||||
"bridge_enabled": self.bridge_enabled,
|
||||
"self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"),
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool:
|
||||
raw = (env.get(name) or "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in _TRUTHY
|
||||
|
||||
|
||||
def _env_int(name: str, env: dict[str, str], default: int) -> int:
|
||||
raw = (env.get(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value >= 1 else default
|
||||
|
||||
|
||||
def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig:
|
||||
"""Build config from environment. Never reads or returns the token value."""
|
||||
source = dict(env if env is not None else os.environ)
|
||||
base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/")
|
||||
lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip()
|
||||
if not _LOOKBACK_RE.match(lookback):
|
||||
lookback = DEFAULT_LOOKBACK
|
||||
environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None
|
||||
return SentryBridgeConfig(
|
||||
base_url=base_url,
|
||||
org=(source.get(ENV_ORG) or "").strip(),
|
||||
project=(source.get(ENV_PROJECT) or "").strip(),
|
||||
environment=environment,
|
||||
lookback=lookback,
|
||||
min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS),
|
||||
bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False),
|
||||
)
|
||||
|
||||
|
||||
def config_with_overrides(
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
org: str | None = None,
|
||||
project: str | None = None,
|
||||
lookback: str | None = None,
|
||||
min_events_for_issue: int | None = None,
|
||||
) -> SentryBridgeConfig:
|
||||
"""Return *config* with explicit per-call overrides applied."""
|
||||
overrides: dict[str, Any] = {}
|
||||
if base_url:
|
||||
overrides["base_url"] = str(base_url).strip().rstrip("/")
|
||||
if org:
|
||||
overrides["org"] = str(org).strip()
|
||||
if project:
|
||||
overrides["project"] = str(project).strip()
|
||||
if lookback:
|
||||
candidate = str(lookback).strip()
|
||||
overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback
|
||||
if min_events_for_issue is not None:
|
||||
overrides["min_events_for_issue"] = max(1, int(min_events_for_issue))
|
||||
return dataclasses.replace(config, **overrides) if overrides else config
|
||||
|
||||
|
||||
def resolve_token(env: dict[str, str] | None = None) -> str:
|
||||
"""Return the Sentry auth token from env only (never logged or returned)."""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(ENV_AUTH_TOKEN) or "").strip()
|
||||
|
||||
|
||||
def assert_configured(config: SentryBridgeConfig, token: str) -> None:
|
||||
"""Fail closed before any network call."""
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
(ENV_BASE_URL, config.base_url),
|
||||
(ENV_ORG, config.org),
|
||||
(ENV_PROJECT, config.project),
|
||||
)
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
raise SentryApiError(
|
||||
"Sentry bridge is not configured; missing " + ", ".join(sorted(missing)),
|
||||
kind=ERROR_NOT_CONFIGURED,
|
||||
)
|
||||
if not token:
|
||||
raise SentryApiError(
|
||||
f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)",
|
||||
kind=ERROR_MISSING_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP layer (injectable)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# http_fn(url, headers, timeout) -> (status, body_bytes, response_headers)
|
||||
HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"]
|
||||
|
||||
|
||||
def _default_http_fn(
|
||||
url: str, headers: dict[str, str], timeout: float
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return (
|
||||
int(response.status),
|
||||
response.read(),
|
||||
{k.lower(): v for k, v in response.headers.items()},
|
||||
)
|
||||
except urllib.error.HTTPError as exc: # status is meaningful
|
||||
try:
|
||||
body = exc.read()
|
||||
except Exception: # noqa: BLE001 - body is best-effort only
|
||||
body = b""
|
||||
return (
|
||||
int(exc.code),
|
||||
body,
|
||||
{k.lower(): v for k, v in (exc.headers or {}).items()},
|
||||
)
|
||||
except urllib.error.URLError as exc:
|
||||
raise SentryApiError(
|
||||
f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc
|
||||
|
||||
|
||||
def parse_next_cursor(link_header: str | None) -> str | None:
|
||||
"""Extract the ``rel="next"`` cursor when more results exist."""
|
||||
if not link_header:
|
||||
return None
|
||||
for part in link_header.split(","):
|
||||
rel = _REL_RE.search(part)
|
||||
if not rel or rel.group(1) != "next":
|
||||
continue
|
||||
results = _RESULTS_RE.search(part)
|
||||
if results and results.group(1).lower() != "true":
|
||||
return None
|
||||
cursor = _CURSOR_RE.search(part)
|
||||
if cursor:
|
||||
return cursor.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _get_json(
|
||||
config: SentryBridgeConfig,
|
||||
path: str,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
token: str,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> tuple[Any, dict[str, str]]:
|
||||
caller = http_fn or _default_http_fn
|
||||
query = urllib.parse.urlencode(
|
||||
{k: v for k, v in params.items() if v not in (None, "")}
|
||||
)
|
||||
url = f"{config.base_url}{path}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "gitea-tools-sentry-bridge/1.0",
|
||||
}
|
||||
status, body, response_headers = caller(url, headers, config.timeout)
|
||||
if status in (401, 403):
|
||||
raise SentryApiError(
|
||||
"Sentry rejected the auth token (unauthorized)",
|
||||
kind=ERROR_MISSING_TOKEN,
|
||||
status=status,
|
||||
)
|
||||
if status >= 500:
|
||||
raise SentryApiError(
|
||||
f"Sentry server error (HTTP {status})",
|
||||
kind=ERROR_UNAVAILABLE,
|
||||
status=status,
|
||||
)
|
||||
if status >= 400:
|
||||
raise SentryApiError(
|
||||
f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status
|
||||
)
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8") or "null")
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SentryApiError(
|
||||
f"Sentry returned an unparseable response: {exc}",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
status=status,
|
||||
) from exc
|
||||
return payload, response_headers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sanitization
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
"""Redact secrets, then replace embedded local paths with a category token.
|
||||
|
||||
``sentry_observability.sanitize_path`` categorizes a string that *is* a
|
||||
path; it must never be applied to whole free-text fields (it would collapse
|
||||
a title or timestamp to ``"other"``). Here it is applied only to substrings
|
||||
that actually match an absolute path.
|
||||
"""
|
||||
text = incident_bridge.redact_text(value)
|
||||
if not text:
|
||||
return ""
|
||||
return _ABS_PATH_RE.sub(
|
||||
lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text
|
||||
)
|
||||
|
||||
|
||||
def sanitize_issue(raw: Any) -> dict[str, Any]:
|
||||
"""Project one raw Sentry issue into a sanitized, LLM-safe summary."""
|
||||
if not isinstance(raw, dict):
|
||||
raise SentryApiError(
|
||||
"Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
issue_id = raw.get("id")
|
||||
if issue_id is None or str(issue_id).strip() == "":
|
||||
raise SentryApiError(
|
||||
"Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
|
||||
try:
|
||||
count = int(raw.get("count"))
|
||||
except (TypeError, ValueError):
|
||||
count = None
|
||||
permalink = _clean(raw.get("permalink"))
|
||||
if "[REDACTED]" in permalink:
|
||||
permalink = ""
|
||||
user_count = raw.get("userCount")
|
||||
return {
|
||||
"id": str(issue_id).strip(),
|
||||
"short_id": _clean(raw.get("shortId")) or None,
|
||||
"title": _clean(raw.get("title"))[:200],
|
||||
"culprit": _clean(raw.get("culprit")) or None,
|
||||
"level": _clean(raw.get("level")) or None,
|
||||
"status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved",
|
||||
"count": count,
|
||||
"user_count": user_count if isinstance(user_count, int) else None,
|
||||
"first_seen": _clean(raw.get("firstSeen")) or None,
|
||||
"last_seen": _clean(raw.get("lastSeen")) or None,
|
||||
"permalink": permalink or None,
|
||||
"metadata_value": _clean(metadata.get("value"))[:500] or None,
|
||||
"metadata_type": _clean(metadata.get("type")) or None,
|
||||
}
|
||||
|
||||
|
||||
def sanitize_event(raw: Any) -> dict[str, Any]:
|
||||
"""Project one raw Sentry event into a sanitized summary."""
|
||||
if not isinstance(raw, dict):
|
||||
raise SentryApiError(
|
||||
"Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
tags: dict[str, str] = {}
|
||||
raw_tags = raw.get("tags")
|
||||
if isinstance(raw_tags, list):
|
||||
# Sentry events return tags as [{"key": ..., "value": ...}, ...]
|
||||
tags = incident_bridge.sanitize_tags(
|
||||
{
|
||||
t.get("key"): t.get("value")
|
||||
for t in raw_tags
|
||||
if isinstance(t, dict) and t.get("key")
|
||||
}
|
||||
)
|
||||
elif isinstance(raw_tags, dict):
|
||||
tags = incident_bridge.sanitize_tags(raw_tags)
|
||||
return {
|
||||
"event_id": _clean(raw.get("eventID") or raw.get("id")) or None,
|
||||
"message": _clean(raw.get("message") or raw.get("title"))[:2000] or None,
|
||||
"date_created": _clean(raw.get("dateCreated")) or None,
|
||||
"platform": _clean(raw.get("platform")) or None,
|
||||
"environment": _clean(raw.get("environment")) or None,
|
||||
"release": _clean(raw.get("release")) or None,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reads
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_issues(
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
token: str,
|
||||
query: str = "is:unresolved",
|
||||
limit: int = DEFAULT_PAGE_SIZE,
|
||||
max_pages: int = DEFAULT_MAX_PAGES,
|
||||
cursor: str | None = None,
|
||||
environment: str | None = None,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List sanitized unresolved Sentry issues, following ``Link`` pagination."""
|
||||
assert_configured(config, token)
|
||||
page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
|
||||
pages_allowed = max(1, int(max_pages or 1))
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
next_cursor = cursor
|
||||
pages_fetched = 0
|
||||
for _ in range(pages_allowed):
|
||||
payload, headers = _get_json(
|
||||
config,
|
||||
config.issues_path(),
|
||||
{
|
||||
"query": query,
|
||||
"statsPeriod": config.lookback,
|
||||
"limit": page_size,
|
||||
"cursor": next_cursor,
|
||||
"environment": environment or config.environment,
|
||||
},
|
||||
token=token,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
pages_fetched += 1
|
||||
if payload is None:
|
||||
payload = []
|
||||
if not isinstance(payload, list):
|
||||
raise SentryApiError(
|
||||
"Sentry issue list response was not a JSON array",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
)
|
||||
issues.extend(sanitize_issue(item) for item in payload)
|
||||
next_cursor = parse_next_cursor(headers.get("link"))
|
||||
if not next_cursor:
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"issues": issues,
|
||||
"count": len(issues),
|
||||
"pages_fetched": pages_fetched,
|
||||
"next_cursor": next_cursor,
|
||||
"inventory_complete": next_cursor is None,
|
||||
"config": config.as_dict(),
|
||||
"query": query,
|
||||
}
|
||||
|
||||
|
||||
def get_issue_events(
|
||||
config: SentryBridgeConfig,
|
||||
issue_id: str,
|
||||
*,
|
||||
token: str,
|
||||
limit: int = 10,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch sanitized recent events plus the latest event for one issue."""
|
||||
assert_configured(config, token)
|
||||
if not str(issue_id or "").strip():
|
||||
raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE)
|
||||
issue_key = str(issue_id).strip()
|
||||
|
||||
payload, _ = _get_json(
|
||||
config,
|
||||
config.issue_events_path(issue_key),
|
||||
{"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))},
|
||||
token=token,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
if payload is None:
|
||||
payload = []
|
||||
if not isinstance(payload, list):
|
||||
raise SentryApiError(
|
||||
"Sentry event list response was not a JSON array",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
)
|
||||
events = [sanitize_event(item) for item in payload]
|
||||
return {
|
||||
"success": True,
|
||||
"issue_id": issue_key,
|
||||
"events": events,
|
||||
"count": len(events),
|
||||
"latest_event": events[0] if events else None,
|
||||
"config": config.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Observation mapping + policy
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def observation_from_issue(
|
||||
issue: dict[str, Any],
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
gitea_org: str | None = None,
|
||||
gitea_repo: str | None = None,
|
||||
latest_event: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a sanitized Sentry issue into a #612 observation dict."""
|
||||
tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {}
|
||||
environment = None
|
||||
if isinstance(latest_event, dict):
|
||||
environment = latest_event.get("environment")
|
||||
environment = environment or config.environment
|
||||
|
||||
observation: dict[str, Any] = {
|
||||
"provider": PROVIDER,
|
||||
"provider_base_url": config.base_url,
|
||||
"provider_org": config.org,
|
||||
"provider_project": config.project,
|
||||
"provider_issue_id": issue.get("id"),
|
||||
"provider_short_id": issue.get("short_id"),
|
||||
"provider_permalink": issue.get("permalink"),
|
||||
"title": issue.get("title"),
|
||||
"culprit": issue.get("culprit"),
|
||||
"summary": issue.get("metadata_value") or issue.get("title"),
|
||||
"level": issue.get("level"),
|
||||
"status": issue.get("status") or "unresolved",
|
||||
"event_count": issue.get("count"),
|
||||
"first_seen": issue.get("first_seen"),
|
||||
"last_seen": issue.get("last_seen"),
|
||||
"environment": environment,
|
||||
"tags": tags,
|
||||
}
|
||||
if gitea_org:
|
||||
observation["gitea_org"] = gitea_org
|
||||
if gitea_repo:
|
||||
observation["gitea_repo"] = gitea_repo
|
||||
return observation
|
||||
|
||||
|
||||
def should_bridge_issue(
|
||||
issue: dict[str, Any], config: SentryBridgeConfig
|
||||
) -> tuple[bool, str]:
|
||||
"""Policy gate: is this Sentry issue worth a durable Gitea issue?"""
|
||||
status = str(issue.get("status") or "").strip().lower()
|
||||
if status and status != "unresolved":
|
||||
return False, f"status '{status}' is not unresolved"
|
||||
count = issue.get("count")
|
||||
threshold = int(config.min_events_for_issue or 1)
|
||||
if isinstance(count, int) and count < threshold:
|
||||
return (
|
||||
False,
|
||||
f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}",
|
||||
)
|
||||
return True, "meets bridge policy"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Watchdog
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def watchdog(
|
||||
db: Any,
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
token: str,
|
||||
apply: bool = False,
|
||||
mappings: Sequence[Any] | None = None,
|
||||
gitea_org: str | None = None,
|
||||
gitea_repo: str | None = None,
|
||||
query: str = "is:unresolved",
|
||||
limit: int = DEFAULT_PAGE_SIZE,
|
||||
max_pages: int = DEFAULT_MAX_PAGES,
|
||||
http_fn: HttpFn | None = None,
|
||||
create_issue_fn: Any = None,
|
||||
comment_issue_fn: Any = None,
|
||||
reconcile_fn: Callable[..., dict[str, Any]] | None = None,
|
||||
fetch_events: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Scan Sentry and reconcile active incidents into Gitea issues.
|
||||
|
||||
Dry-run by default. ``apply=True`` additionally requires the bridge to be
|
||||
explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``.
|
||||
|
||||
``comment_issue_fn`` carries the sanctioned issue-comment route used for
|
||||
AC4 recurrence comments on already-linked issues; dry runs never comment.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"success": False,
|
||||
"apply": bool(apply),
|
||||
"scanned": 0,
|
||||
"reconciled": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"results": [],
|
||||
"reasons": [],
|
||||
"config": config.as_dict(),
|
||||
"raw_incident_assignable": False,
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
|
||||
if apply and not config.bridge_enabled:
|
||||
result["reasons"].append(
|
||||
f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)"
|
||||
)
|
||||
result["error_kind"] = ERROR_BRIDGE_DISABLED
|
||||
return result
|
||||
|
||||
try:
|
||||
listing = list_issues(
|
||||
config,
|
||||
token=token,
|
||||
query=query,
|
||||
limit=limit,
|
||||
max_pages=max_pages,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
except SentryApiError as exc:
|
||||
result["reasons"].append(str(exc))
|
||||
result.update(exc.as_dict())
|
||||
return result
|
||||
|
||||
reconciler = reconcile_fn or incident_bridge.reconcile_incident
|
||||
result["inventory_complete"] = listing.get("inventory_complete", False)
|
||||
result["pages_fetched"] = listing.get("pages_fetched", 0)
|
||||
|
||||
for issue in listing.get("issues", []):
|
||||
result["scanned"] += 1
|
||||
eligible, reason = should_bridge_issue(issue, config)
|
||||
if not eligible:
|
||||
result["skipped"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": (
|
||||
ACTION_SKIPPED_THRESHOLD
|
||||
if "threshold" in reason
|
||||
else ACTION_SKIPPED_STATUS
|
||||
),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
latest_event = None
|
||||
if fetch_events:
|
||||
try:
|
||||
events = get_issue_events(
|
||||
config, issue["id"], token=token, limit=1, http_fn=http_fn
|
||||
)
|
||||
latest_event = events.get("latest_event")
|
||||
except SentryApiError as exc:
|
||||
# Event enrichment is best-effort; the issue itself still bridges.
|
||||
result["reasons"].append(
|
||||
f"event fetch failed for {issue.get('id')}: {exc}"
|
||||
)
|
||||
|
||||
observation = observation_from_issue(
|
||||
issue,
|
||||
config,
|
||||
gitea_org=gitea_org,
|
||||
gitea_repo=gitea_repo,
|
||||
latest_event=latest_event,
|
||||
)
|
||||
try:
|
||||
reconciled = reconciler(
|
||||
db,
|
||||
observation=observation,
|
||||
mappings=list(mappings or []),
|
||||
apply=bool(apply),
|
||||
create_issue_fn=create_issue_fn,
|
||||
comment_issue_fn=comment_issue_fn,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - one bad issue must not abort the scan
|
||||
result["failed"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": ACTION_FAILED,
|
||||
"reason": incident_bridge.redact_text(exc),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
result["reconciled"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": ACTION_RECONCILED,
|
||||
"outcome": reconciled.get("outcome"),
|
||||
"gitea_issue": reconciled.get("gitea_issue"),
|
||||
"existing_link": reconciled.get("existing_link"),
|
||||
"recurrence_comment": reconciled.get("recurrence_comment"),
|
||||
"reasons": reconciled.get("reasons"),
|
||||
}
|
||||
)
|
||||
|
||||
result["success"] = result["failed"] == 0
|
||||
if not result["results"]:
|
||||
result["reasons"].append("no Sentry issues matched the scan window/policy")
|
||||
return result
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
|
||||
|
||||
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
|
||||
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
|
||||
watchdog check-ins are visible in a *self-hosted* Sentry at
|
||||
``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow
|
||||
source of truth (Gitea stays canonical).
|
||||
|
||||
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
|
||||
|
||||
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
|
||||
empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool
|
||||
behaviour and API-call patterns are unchanged (acceptance criterion 1).
|
||||
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
|
||||
package, or any capture error must never break an MCP tool success path. Every
|
||||
public entry point swallows its own exceptions.
|
||||
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
|
||||
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
|
||||
session-state, full prompt bodies, and full filesystem paths never leave here.
|
||||
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
|
||||
importable and testable without it installed.
|
||||
|
||||
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
|
||||
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
|
||||
the sanctioned Gitea issue/comment path via the #612 incident bridge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# Reuse the most comprehensive existing scrubber so redaction stays consistent
|
||||
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
|
||||
# ids, session ids, user:pass@host).
|
||||
from incident_bridge import redact_text as _redact_text
|
||||
|
||||
# Second, complementary scrubber: catches bare ``token <value>`` /
|
||||
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
|
||||
# incident-bridge delimiter patterns miss.
|
||||
from gitea_audit import _redact_str as _redact_prefixes
|
||||
|
||||
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
|
||||
try: # pragma: no cover - trivial import guard
|
||||
import sentry_sdk # type: ignore
|
||||
except Exception: # pragma: no cover - absence is a supported state
|
||||
sentry_sdk = None # type: ignore
|
||||
|
||||
|
||||
# ── Env var names (single source of truth) ──────────────────────────────────
|
||||
ENV_ENABLED = "MCP_SENTRY_ENABLED"
|
||||
ENV_DSN = "SENTRY_DSN"
|
||||
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||
ENV_RELEASE = "SENTRY_RELEASE"
|
||||
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
|
||||
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
REDACTED_PATH = "[REDACTED_PATH]"
|
||||
|
||||
|
||||
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
|
||||
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
|
||||
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
|
||||
# of these keys (or an explicit slug) to ``monitor_checkin``.
|
||||
MONITOR_SLUGS: dict[str, str] = {
|
||||
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
|
||||
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
|
||||
"allocator_health": "gitea-mcp-allocator-health",
|
||||
"namespace_health": "gitea-mcp-namespace-health",
|
||||
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
|
||||
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
|
||||
}
|
||||
|
||||
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
|
||||
|
||||
|
||||
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
|
||||
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
|
||||
# a caller cannot accidentally leak a sensitive value through a tag.
|
||||
ALLOWED_TAG_KEYS = frozenset({
|
||||
"role",
|
||||
"profile",
|
||||
"namespace",
|
||||
"repo",
|
||||
"org",
|
||||
"issue_number",
|
||||
"pr_number",
|
||||
"blocker_type",
|
||||
"workflow_hash",
|
||||
"session_id_hash", # hash only — never the raw session id
|
||||
"pid",
|
||||
"worktree_category", # category, never the full sensitive path
|
||||
"lease_comment_id",
|
||||
"expected_head_sha",
|
||||
"current_head_sha",
|
||||
"terminal_lock_state",
|
||||
"capability",
|
||||
"mutation_tool",
|
||||
})
|
||||
|
||||
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
|
||||
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
|
||||
|
||||
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
|
||||
# (raw session-state, full prompt/comment bodies, private config blobs, raw
|
||||
# headers).
|
||||
_FORBIDDEN_EXTRA_KEYS = frozenset({
|
||||
"prompt",
|
||||
"prompt_body",
|
||||
"next_prompt",
|
||||
"body",
|
||||
"raw_body",
|
||||
"session_state",
|
||||
"session_state_contents",
|
||||
"config",
|
||||
"config_contents",
|
||||
"private_config",
|
||||
"headers",
|
||||
"authorization",
|
||||
})
|
||||
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class SentryConfig:
|
||||
"""Immutable snapshot of the Sentry env configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
dsn: str | None = None
|
||||
environment: str = "development"
|
||||
release: str | None = None
|
||||
traces_sample_rate: float = 0.0
|
||||
enable_logs: bool = False
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""True only when the operator both opted in *and* supplied a DSN.
|
||||
|
||||
This is the single gate that keeps the feature off by default: enabling
|
||||
the flag without a DSN (or vice versa) sends nothing.
|
||||
"""
|
||||
return bool(self.enabled and self.dsn)
|
||||
|
||||
def safe_summary(self) -> dict[str, Any]:
|
||||
"""Operator-facing status with **no** DSN value (only presence)."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"dsn_present": bool(self.dsn),
|
||||
"environment": self.environment,
|
||||
"release": self.release,
|
||||
"traces_sample_rate": self.traces_sample_rate,
|
||||
"enable_logs": self.enable_logs,
|
||||
"active": self.active,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, env: dict[str, str]) -> bool:
|
||||
return (env.get(name) or "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
|
||||
raw = (env.get(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
|
||||
if val < 0.0:
|
||||
return 0.0
|
||||
if val > 1.0:
|
||||
return 1.0
|
||||
return val
|
||||
|
||||
|
||||
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
|
||||
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
dsn = (env.get(ENV_DSN) or "").strip() or None
|
||||
return SentryConfig(
|
||||
enabled=_env_bool(ENV_ENABLED, env),
|
||||
dsn=dsn,
|
||||
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
|
||||
release=(env.get(ENV_RELEASE) or "").strip() or None,
|
||||
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
|
||||
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
|
||||
)
|
||||
|
||||
|
||||
def sdk_available() -> bool:
|
||||
"""True when the optional ``sentry_sdk`` package is importable."""
|
||||
return sentry_sdk is not None
|
||||
|
||||
|
||||
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||
def sanitize_path(value: Any) -> str:
|
||||
"""Reduce a filesystem path to a non-sensitive *category* token.
|
||||
|
||||
Full local paths must never be sent. We keep only a coarse worktree
|
||||
category derived from the path shape (author/reviewer/merger/reconciler/
|
||||
branches/root/other).
|
||||
"""
|
||||
text = "" if value is None else str(value)
|
||||
low = text.lower()
|
||||
if not text:
|
||||
return "unknown"
|
||||
# Order matters: more specific role markers before the generic "branches".
|
||||
if "reconcile" in low:
|
||||
return "reconciler"
|
||||
if "review" in low:
|
||||
return "reviewer"
|
||||
if "merge" in low or "merger" in low:
|
||||
return "merger"
|
||||
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
|
||||
return "author"
|
||||
if "/branches/" in low:
|
||||
return "branches"
|
||||
if low.rstrip("/").endswith("gitea-tools"):
|
||||
return "root"
|
||||
return "other"
|
||||
|
||||
|
||||
def redact_value(value: Any) -> Any:
|
||||
"""Recursively redact a JSON-able value: secret text, absolute paths, and
|
||||
known-sensitive dict keys are removed. Fail closed — any error drops the
|
||||
value entirely rather than risk leaking it."""
|
||||
try:
|
||||
if isinstance(value, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in value.items():
|
||||
key = str(k)
|
||||
low = key.lower()
|
||||
if low in _FORBIDDEN_EXTRA_KEYS or any(
|
||||
s in low
|
||||
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
|
||||
):
|
||||
out[key] = REDACTED
|
||||
continue
|
||||
out[key] = redact_value(v)
|
||||
return out
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [redact_value(v) for v in value]
|
||||
if isinstance(value, str):
|
||||
scrubbed = _redact_text(value)
|
||||
scrubbed = _redact_prefixes(scrubbed)
|
||||
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
|
||||
return scrubbed
|
||||
return value
|
||||
except Exception:
|
||||
return REDACTED
|
||||
|
||||
|
||||
def hash_session_id(session_id: Any) -> str:
|
||||
"""Short, stable, non-reversible fingerprint of a session id."""
|
||||
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
|
||||
return digest[:12]
|
||||
|
||||
|
||||
def build_tags(**kwargs: Any) -> dict[str, str]:
|
||||
"""Return a scrubbed, allowlisted tag dict.
|
||||
|
||||
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
|
||||
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
|
||||
key, or a value that still contains redacted material after scrubbing, is
|
||||
dropped.
|
||||
"""
|
||||
raw: dict[str, Any] = dict(kwargs)
|
||||
|
||||
# Hash the session id — never emit it raw.
|
||||
session_id = raw.pop("session_id", None)
|
||||
if session_id and "session_id_hash" not in raw:
|
||||
raw["session_id_hash"] = hash_session_id(session_id)
|
||||
|
||||
# A full worktree path collapses to a category tag.
|
||||
wt = raw.pop("worktree_path", None)
|
||||
if wt and "worktree_category" not in raw:
|
||||
raw["worktree_category"] = sanitize_path(wt)
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for key, val in raw.items():
|
||||
if key not in ALLOWED_TAG_KEYS:
|
||||
continue
|
||||
if val is None:
|
||||
continue
|
||||
scrubbed = redact_value(val)
|
||||
text = str(scrubbed)
|
||||
if not text or REDACTED in text or REDACTED_PATH in text:
|
||||
continue
|
||||
if len(text) > 200:
|
||||
text = text[:200] + "…"
|
||||
out[key] = text
|
||||
return out
|
||||
|
||||
|
||||
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
|
||||
"""Sentry ``before_send`` / ``before_send_log`` hook.
|
||||
|
||||
Recursively redacts the outgoing event. On *any* failure it returns ``None``
|
||||
so the event is dropped rather than sent unscrubbed (fail closed for
|
||||
redaction).
|
||||
"""
|
||||
try:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
scrubbed = redact_value(event)
|
||||
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
|
||||
scrubbed.pop("server_name", None)
|
||||
return scrubbed
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── Event builders (pure, independently testable) ───────────────────────────
|
||||
def build_blocker_event(
|
||||
blocker_type: str,
|
||||
*,
|
||||
message: str | None = None,
|
||||
next_action: str | None = None,
|
||||
level: str = "warning",
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a redacted, structured Sentry event for a workflow blocker.
|
||||
|
||||
``next_action`` maps the issue's "canonical next action when available"
|
||||
requirement (acceptance criterion 7).
|
||||
"""
|
||||
merged_tags = dict(tags or {})
|
||||
merged_tags.setdefault("blocker_type", blocker_type)
|
||||
safe_tags = build_tags(**merged_tags)
|
||||
|
||||
safe_extra = redact_value(dict(extra or {}))
|
||||
if next_action:
|
||||
# A short canonical next action is allowed (it is not a full prompt).
|
||||
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"message": redact_value(message or blocker_type),
|
||||
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
|
||||
"logger": "gitea-mcp.workflow",
|
||||
"tags": safe_tags,
|
||||
"extra": safe_extra,
|
||||
"fingerprint": ["workflow-blocker", blocker_type],
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def build_checkin_payload(
|
||||
monitor: str,
|
||||
status: str,
|
||||
*,
|
||||
check_in_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
|
||||
|
||||
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
|
||||
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
|
||||
silently send a malformed check-in.
|
||||
"""
|
||||
if status not in _CHECKIN_STATUSES:
|
||||
raise ValueError(
|
||||
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
|
||||
)
|
||||
slug = MONITOR_SLUGS.get(monitor, monitor)
|
||||
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
|
||||
if check_in_id:
|
||||
payload["check_in_id"] = str(check_in_id)
|
||||
if duration is not None:
|
||||
try:
|
||||
payload["duration"] = float(duration)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return payload
|
||||
|
||||
|
||||
# ── Runtime init + capture (fail open) ──────────────────────────────────────
|
||||
_STATE: dict[str, Any] = {"initialized": False, "config": None}
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
return bool(_STATE.get("initialized"))
|
||||
|
||||
|
||||
def active_config() -> SentryConfig | None:
|
||||
return _STATE.get("config")
|
||||
|
||||
|
||||
def reset_for_tests() -> None:
|
||||
"""Clear module init state. Test-only helper (never called in production)."""
|
||||
_STATE["initialized"] = False
|
||||
_STATE["config"] = None
|
||||
|
||||
|
||||
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
|
||||
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
|
||||
|
||||
Idempotent and never raises. Returns an operator-safe status dict (no DSN
|
||||
value). Behaviour is unchanged when the feature is off.
|
||||
"""
|
||||
cfg = config or load_config()
|
||||
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
|
||||
try:
|
||||
if not cfg.active:
|
||||
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
|
||||
_STATE["config"] = cfg
|
||||
return status
|
||||
if not sdk_available():
|
||||
status["reason"] = "sentry_sdk not installed"
|
||||
_STATE["config"] = cfg
|
||||
return status
|
||||
|
||||
init_kwargs: dict[str, Any] = {
|
||||
"dsn": cfg.dsn,
|
||||
"environment": cfg.environment,
|
||||
"release": cfg.release,
|
||||
"traces_sample_rate": cfg.traces_sample_rate,
|
||||
"before_send": scrub_event,
|
||||
"send_default_pii": False,
|
||||
}
|
||||
if cfg.enable_logs:
|
||||
# sentry-sdk 2.x captures Python logs as structured logs when the
|
||||
# experimental logs feature is enabled; scrub those too.
|
||||
init_kwargs["_experiments"] = {
|
||||
"enable_logs": True,
|
||||
"before_send_log": scrub_event,
|
||||
}
|
||||
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
|
||||
_STATE["initialized"] = True
|
||||
_STATE["config"] = cfg
|
||||
status["initialized"] = True
|
||||
status["reason"] = "sentry initialised"
|
||||
except Exception as exc: # fail open: observability must not block startup
|
||||
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
|
||||
_STATE["initialized"] = False
|
||||
return status
|
||||
|
||||
|
||||
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
|
||||
for key, val in tags.items():
|
||||
try:
|
||||
scope.set_tag(key, val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def capture_workflow_blocker(
|
||||
blocker_type: str,
|
||||
*,
|
||||
message: str | None = None,
|
||||
next_action: str | None = None,
|
||||
level: str = "warning",
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture a fail-closed workflow blocker as a structured Sentry event.
|
||||
|
||||
Always returns the redacted event dict (so callers/tests can inspect it),
|
||||
and sends it to Sentry only when initialised. Fail open.
|
||||
"""
|
||||
event = build_blocker_event(
|
||||
blocker_type,
|
||||
message=message,
|
||||
next_action=next_action,
|
||||
level=level,
|
||||
tags=tags,
|
||||
extra=extra,
|
||||
)
|
||||
try:
|
||||
if is_initialized() and sdk_available():
|
||||
sentry_sdk.capture_event(event) # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
return event
|
||||
|
||||
|
||||
def capture_exception(
|
||||
exc: BaseException,
|
||||
*,
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Capture a runtime exception with scrubbed tags. Fail open.
|
||||
|
||||
Returns True only when the event was handed to an initialised SDK.
|
||||
"""
|
||||
try:
|
||||
if not (is_initialized() and sdk_available()):
|
||||
return False
|
||||
safe_tags = build_tags(**(tags or {}))
|
||||
safe_extra = redact_value(dict(extra or {}))
|
||||
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
|
||||
_set_scope_tags(scope, safe_tags)
|
||||
for key, val in safe_extra.items():
|
||||
try:
|
||||
scope.set_extra(key, val)
|
||||
except Exception:
|
||||
pass
|
||||
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def monitor_checkin(
|
||||
monitor: str,
|
||||
status: str,
|
||||
*,
|
||||
check_in_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Send a Sentry cron check-in for a watchdog job. Fail open.
|
||||
|
||||
Returns the payload (for inspection/tests), or ``None`` if the status was
|
||||
invalid. Only transmits when initialised.
|
||||
"""
|
||||
try:
|
||||
payload = build_checkin_payload(
|
||||
monitor, status, check_in_id=check_in_id, duration=duration
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
|
||||
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Session-immutable MCP mutation context (#714).
|
||||
|
||||
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||
|
||||
Capability resolution and mutation gates must evaluate only the active
|
||||
profile for the requested remote. Silent cross-host / cross-profile
|
||||
substitution is forbidden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SessionContext:
|
||||
"""One atomic, immutable process-session binding."""
|
||||
|
||||
profile_name: str | None
|
||||
remote: str | None
|
||||
host: str | None
|
||||
identity: str | None
|
||||
repository: str | None
|
||||
org: str | None
|
||||
role_kind: str | None
|
||||
expected_username: str | None
|
||||
source: str
|
||||
pid: int
|
||||
canonical_repository_root: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_name": self.profile_name,
|
||||
"remote": self.remote,
|
||||
"host": self.host,
|
||||
"identity": self.identity,
|
||||
"repository": self.repository,
|
||||
"org": self.org,
|
||||
"role_kind": self.role_kind,
|
||||
"expected_username": self.expected_username,
|
||||
"source": self.source,
|
||||
"pid": self.pid,
|
||||
"canonical_repository_root": self.canonical_repository_root,
|
||||
}
|
||||
|
||||
|
||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||
# sanctioned rebind atomic across concurrent MCP calls.
|
||||
_SESSION_CONTEXT: _SessionContext | None = None
|
||||
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _reset_session_context_for_testing() -> None:
|
||||
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||
|
||||
Production sessions transition only through process startup/PID change or
|
||||
the explicit profile-activation rebind. Keeping this helper private and
|
||||
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||
bypass.
|
||||
"""
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||
global _SESSION_CONTEXT
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def get_session_context() -> dict[str, Any] | None:
|
||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None:
|
||||
return None
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def profile_host(profile: dict | None) -> str | None:
|
||||
"""Hostname from profile base_url, lowercased, or None."""
|
||||
if not profile:
|
||||
return None
|
||||
base = (profile.get("base_url") or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
return host or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||
"""Hostname for a known remote key."""
|
||||
if not remote or not remotes:
|
||||
return None
|
||||
entry = remotes.get(remote) or {}
|
||||
return (entry.get("host") or "").strip().lower() or None
|
||||
|
||||
|
||||
def profile_matches_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> bool:
|
||||
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||
if not profile or not remote:
|
||||
return False
|
||||
r_host = remote_host(remote, remotes)
|
||||
p_host = profile_host(profile)
|
||||
if r_host and p_host:
|
||||
return r_host == p_host
|
||||
# Fall back to context name heuristics when base_url missing.
|
||||
ctx = (profile.get("context") or "").strip().lower()
|
||||
if not ctx or not contexts:
|
||||
return False
|
||||
ctx_data = contexts.get(ctx) or {}
|
||||
gitea = ctx_data.get("gitea") or {}
|
||||
base = (gitea.get("base_url") or "").strip()
|
||||
if not base or not r_host:
|
||||
return False
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
except Exception:
|
||||
return False
|
||||
return bool(c_host) and c_host == r_host
|
||||
|
||||
|
||||
def bind_session_context(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "bind",
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
)
|
||||
|
||||
|
||||
def _bind_session_context_unlocked(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None,
|
||||
org: str | None,
|
||||
role_kind: str | None,
|
||||
expected_username: str | None,
|
||||
source: str,
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a complete immutable context while the caller holds the lock."""
|
||||
global _SESSION_CONTEXT
|
||||
_SESSION_CONTEXT = _SessionContext(
|
||||
profile_name=(profile_name or "").strip() or None,
|
||||
remote=(remote or "").strip() or None,
|
||||
host=(host or "").strip().lower() or None,
|
||||
identity=(identity or "").strip() or None,
|
||||
repository=(repository or "").strip() or None,
|
||||
org=(org or "").strip() or None,
|
||||
role_kind=(role_kind or "").strip() or None,
|
||||
expected_username=(expected_username or "").strip() or None,
|
||||
source=source,
|
||||
pid=os.getpid(),
|
||||
canonical_repository_root=(canonical_repository_root or "").strip() or None,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def seed_session_context_if_unbound(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "seed",
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind only when this process has no current context.
|
||||
|
||||
A changed environment or an interleaved call is not a session boundary and
|
||||
therefore cannot replace an established binding. A newly started/forked
|
||||
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||
"""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def assess_session_context(
|
||||
*,
|
||||
profile_name: str | None,
|
||||
remote: str | None,
|
||||
host: str | None = None,
|
||||
identity: str | None = None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
canonical_repository_root: str | None = None,
|
||||
require_bound: bool = False,
|
||||
require_complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Compare live values against the bound session context.
|
||||
|
||||
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||
``require_bound`` is false, does not block (caller may seed). When
|
||||
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||
additionally fails closed when the binding carries no verified
|
||||
repository/organization identity, so a mutation can never run against an
|
||||
unknown repository (#714).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
bound = _SESSION_CONTEXT
|
||||
ctx = bound.as_dict() if bound is not None else None
|
||||
if ctx is None or ctx.get("pid") != os.getpid():
|
||||
if require_bound:
|
||||
reasons.append(
|
||||
"session mutation context is unbound; call gitea_whoami or "
|
||||
"gitea_activate_profile before mutating (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
return _assessment(True, reasons, ctx)
|
||||
|
||||
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||
reasons.append(
|
||||
"session repository/organization identity is unverified "
|
||||
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||
"a mutation cannot proceed without a verified workspace "
|
||||
"repository (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
|
||||
live_profile = (profile_name or "").strip() or None
|
||||
live_remote = (remote or "").strip() or None
|
||||
live_host = (host or "").strip().lower() or None
|
||||
live_identity = (identity or "").strip() or None
|
||||
live_repo = (repository or "").strip() or None
|
||||
live_org = (org or "").strip() or None
|
||||
live_canonical = (canonical_repository_root or "").strip() or None
|
||||
|
||||
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||
reasons.append(
|
||||
f"profile drift: live '{live_profile}' != bound "
|
||||
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||
reasons.append(
|
||||
f"remote drift: live '{live_remote}' != bound "
|
||||
f"'{ctx.get('remote')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||
reasons.append(
|
||||
f"host drift: live '{live_host}' != bound "
|
||||
f"'{ctx.get('host')}' (fail closed)"
|
||||
)
|
||||
if (
|
||||
ctx.get("identity")
|
||||
and live_identity
|
||||
and live_identity != ctx.get("identity")
|
||||
):
|
||||
reasons.append(
|
||||
f"identity drift: live '{live_identity}' != bound "
|
||||
f"'{ctx.get('identity')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||
reasons.append(
|
||||
f"repository drift: live '{live_repo}' != bound "
|
||||
f"'{ctx.get('repository')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||
reasons.append(
|
||||
f"org drift: live '{live_org}' != bound "
|
||||
f"'{ctx.get('org')}' (fail closed)"
|
||||
)
|
||||
bound_canonical = ctx.get("canonical_repository_root")
|
||||
if bound_canonical and live_canonical and live_canonical != bound_canonical:
|
||||
reasons.append(
|
||||
f"canonical repository root drift: live '{live_canonical}' != bound "
|
||||
f"'{bound_canonical}' (forged or conflicting cross-repository "
|
||||
"binding, fail closed)"
|
||||
)
|
||||
|
||||
expected = expected_username or ctx.get("expected_username")
|
||||
if expected and live_identity and live_identity != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{live_identity}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
|
||||
return _assessment(not reasons, reasons, ctx)
|
||||
|
||||
|
||||
def assess_identity_match(
|
||||
*,
|
||||
authenticated: str | None,
|
||||
expected_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when profile declares a username that does not match live auth."""
|
||||
reasons: list[str] = []
|
||||
auth = (authenticated or "").strip() or None
|
||||
expected = (expected_username or "").strip() or None
|
||||
if expected and auth and auth != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{auth}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"authenticated": auth,
|
||||
"expected_username": expected,
|
||||
}
|
||||
|
||||
|
||||
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||
|
||||
|
||||
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||
match = _REPO_SLUG_RE.match(slug or "")
|
||||
if not match:
|
||||
return None
|
||||
return match.group("org"), match.group("repo")
|
||||
|
||||
|
||||
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||
left = (org or "").strip()
|
||||
right = (repo or "").strip()
|
||||
if not left or not right:
|
||||
return None
|
||||
return f"{left}/{right}"
|
||||
|
||||
|
||||
def declared_allowed_repositories(
|
||||
profile: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> list[str]:
|
||||
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||
|
||||
This list is an authorization boundary, never the session binding itself:
|
||||
the verified workspace selects exactly one entry (see
|
||||
:func:`assess_repository_scope`).
|
||||
|
||||
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||
an empty scope (which would fail open). Read-only diagnostics may use
|
||||
strict=False.
|
||||
"""
|
||||
if not profile:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"profile unresolved; cannot declare allowed_repositories "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
raw = profile.get("allowed_repositories")
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"allowed_repositories must be a list of owner/repository slugs "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
slugs: list[str] = []
|
||||
errors: list[str] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str):
|
||||
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||
continue
|
||||
parsed = parse_repository_slug(entry)
|
||||
if not parsed:
|
||||
errors.append(
|
||||
f"malformed allowed_repositories entry {entry!r} "
|
||||
"(expected owner/repository)"
|
||||
)
|
||||
continue
|
||||
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||
if strict and errors:
|
||||
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||
return slugs
|
||||
|
||||
|
||||
def assess_repository_scope(
|
||||
*,
|
||||
workspace_slug: str | None,
|
||||
allowed: list[str] | None,
|
||||
profile_name: str | None = None,
|
||||
require_scope: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize the verified workspace repository against the profile allowlist.
|
||||
|
||||
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||
several repositories still binds to the single verified one, and never to
|
||||
the list as a whole.
|
||||
|
||||
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||
workspace remote cannot self-authorize without a configured boundary.
|
||||
"""
|
||||
scope = list(allowed or [])
|
||||
reasons: list[str] = []
|
||||
name = profile_name or "(active profile)"
|
||||
if not scope:
|
||||
if require_scope:
|
||||
reasons.append(
|
||||
f"mutation denied: profile '{name}' has no non-empty "
|
||||
"allowed_repositories authorization boundary (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": False,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
if not workspace_slug:
|
||||
reasons.append(
|
||||
f"no verified workspace repository could be established for profile "
|
||||
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||
)
|
||||
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||
reasons.append(
|
||||
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||
f"is not authorized by profile '{name}' allowed_repositories "
|
||||
f"{sorted(scope)} (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": sorted(scope),
|
||||
}
|
||||
|
||||
|
||||
def assess_repository_override(
|
||||
*,
|
||||
requested_org: str | None,
|
||||
requested_repo: str | None,
|
||||
bound_org: str | None,
|
||||
bound_repo: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||
|
||||
A mutation request is never allowed to establish, complete, or replace the
|
||||
binding — it may only be checked against it.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
req_org = (requested_org or "").strip() or None
|
||||
req_repo = (requested_repo or "").strip() or None
|
||||
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||
reasons.append(
|
||||
f"repository override denial: request targets '{req_repo}' but the "
|
||||
f"session is bound to '{bound_repo}' (fail closed)"
|
||||
)
|
||||
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||
reasons.append(
|
||||
f"organization override denial: request targets '{req_org}' but the "
|
||||
f"session is bound to '{bound_org}' (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def filter_profiles_for_remote(
|
||||
config: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
) -> list[str]:
|
||||
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||
if not config or not remote:
|
||||
return []
|
||||
profiles = config.get("profiles") or {}
|
||||
contexts = config.get("contexts") or {}
|
||||
names: list[str] = []
|
||||
for name, data in profiles.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if not data.get("enabled", True):
|
||||
continue
|
||||
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||
names.append(name)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def profile_allowed_for_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether the active profile may serve *remote*."""
|
||||
reasons: list[str] = []
|
||||
if not profile:
|
||||
reasons.append("active profile unresolved (fail closed)")
|
||||
return {"proven": False, "block": True, "reasons": reasons}
|
||||
if not remote:
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||
# first call may establish the process remote/host pin; after that,
|
||||
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||
# require positive host/context alignment here.
|
||||
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||
p_host = profile_host(profile) or "(none)"
|
||||
r_host = remote_host(remote, remotes) or "(none)"
|
||||
reasons.append(
|
||||
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def mutation_context_audit_fields(
|
||||
ctx: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fields to include in pre-mutation audit records."""
|
||||
data = ctx if ctx is not None else get_session_context()
|
||||
if not data:
|
||||
return {
|
||||
"session_context_bound": False,
|
||||
"session_profile": None,
|
||||
"session_remote": None,
|
||||
"session_host": None,
|
||||
"session_identity": None,
|
||||
"session_repository": None,
|
||||
"session_org": None,
|
||||
}
|
||||
return {
|
||||
"session_context_bound": True,
|
||||
"session_profile": data.get("profile_name"),
|
||||
"session_remote": data.get("remote"),
|
||||
"session_host": data.get("host"),
|
||||
"session_identity": data.get("identity"),
|
||||
"session_repository": data.get("repository"),
|
||||
"session_org": data.get("org"),
|
||||
"session_role_kind": data.get("role_kind"),
|
||||
"session_context_source": data.get("source"),
|
||||
"session_canonical_repository_root": data.get("canonical_repository_root"),
|
||||
}
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(reasons),
|
||||
"bound_context": dict(ctx) if ctx else None,
|
||||
"audit": mutation_context_audit_fields(ctx),
|
||||
}
|
||||
@@ -35,3 +35,11 @@ Install for Codex:
|
||||
```
|
||||
|
||||
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
Which tools actually exist is documented in
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md), which a test
|
||||
holds equal to the registered set. Never plan a mutation against a tool that is
|
||||
not listed there — that is the #781 failure mode, where a documented
|
||||
`gitea_edit_issue` did not exist until execution time.
|
||||
|
||||
@@ -168,6 +168,42 @@ Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
|
||||
proposed push before running it; `gitea_audit_stable_branch_contamination` to
|
||||
inspect or (reconciler-only) clear the marker.
|
||||
|
||||
## Runtime Recovery Protection (#630)
|
||||
|
||||
MCP connectivity is recovered through **sanctioned reconnect/restart only**:
|
||||
host auto-reconnect, an explicit client reconnect, an IDE/client relaunch, or an
|
||||
operator-owned restart. Worker sessions must never kill the daemons their own
|
||||
proof depends on.
|
||||
|
||||
**Forbidden for author/reviewer/merger sessions:**
|
||||
|
||||
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
|
||||
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
|
||||
- Any pattern broad enough to sweep unrelated namespaces (`pkill -f python`),
|
||||
even when it never names MCP.
|
||||
|
||||
**Allowed (never blocked):** read-only inspection (`ps aux | grep mcp_server`),
|
||||
and process management unrelated to the daemons — a `kill` of some other pid is
|
||||
reported as *ambiguous*, not as contamination.
|
||||
|
||||
**What happens on a detected attempt:** the session is marked
|
||||
workflow-contaminated (durable marker, redacted command summary + session id +
|
||||
remote + role). While contaminated, all review / merge / close / completion
|
||||
mutations fail closed. `comment_issue` and `lock_issue` remain allowed so the
|
||||
contaminated worker can post the durable audit comment and hand off.
|
||||
Contamination **cannot be self-cleared** — only a reconciler audit may clear it,
|
||||
and it does not expire with the session-state TTL. The final report must surface
|
||||
the contaminated recovery and must not claim a clean session.
|
||||
|
||||
Operator-authorized host maintenance stays permitted, but the authorization is
|
||||
read from the operator's environment, never from a tool argument: a session must
|
||||
not be able to authorize itself.
|
||||
|
||||
Tooling: call `gitea_record_daemon_process_kill_attempt` to classify/record a
|
||||
proposed command before running it; `gitea_audit_runtime_recovery_contamination`
|
||||
to inspect or (reconciler-only) clear the marker. Full contrast in
|
||||
`docs/mcp-namespace-eof-recovery.md`.
|
||||
|
||||
## Shell Spawn Hard-Stop Rule
|
||||
|
||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||
@@ -216,6 +252,15 @@ Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
|
||||
- Never place raw tokens in LLM/MCP config.
|
||||
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md) is the canonical
|
||||
list of registered tools, held equal to the live registry by a test. A tool that
|
||||
is not listed there does not exist — do not scope work around it (#781).
|
||||
|
||||
Issue content is edited with `gitea_edit_issue` (title/body only, read-after-write
|
||||
verified). `gitea_edit_pr` is pull-request-only and never accepts an issue number.
|
||||
|
||||
## Controller Handoff
|
||||
|
||||
Every task must end with a section titled exactly `Controller Handoff`. Compact
|
||||
@@ -224,6 +269,17 @@ format canonical field set per issue #182; mode-specific schemas in
|
||||
for the loaded workflow mode — not the legacy compact block alone.
|
||||
`review_proofs.assess_controller_handoff()` validates presence.
|
||||
|
||||
## Canonical self-propagating handoff
|
||||
|
||||
Every workflow mode also carries the cross-role handoff block defined in
|
||||
[`schemas/self-propagating-handoff.md`](schemas/self-propagating-handoff.md)
|
||||
(#626). Each actor consumes exactly one canonical handoff, performs exactly one
|
||||
authorized role, posts the result to the Gitea issue or PR thread, and emits the
|
||||
next complete handoff — until the controller records final closure. The block
|
||||
must be posted to Gitea, not returned in chat alone, and the next prompt is not
|
||||
an optional prose section. `self_propagating_handoff.py` implements the schema;
|
||||
`final_report_validator.py` enforces it as `shared.self_propagating_handoff`.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
@@ -44,3 +44,7 @@ mutations occurred).
|
||||
```
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue thread.
|
||||
|
||||
@@ -29,3 +29,7 @@ use `none` where nothing occurred. Validated by
|
||||
* Read-only diagnostics:
|
||||
* Blockers:
|
||||
* Safe next action: (fresh run for the next PR)
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread.
|
||||
|
||||
@@ -39,6 +39,7 @@ occurred).
|
||||
- Git ref mutations:
|
||||
- MCP/Gitea mutations:
|
||||
- Reconciliation mutations:
|
||||
- Terminal label cleanup:
|
||||
- External-state mutations:
|
||||
- Read-only diagnostics:
|
||||
- Blockers:
|
||||
@@ -50,4 +51,15 @@ occurred).
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
|
||||
`Terminal label cleanup` (#780) reports the `pr_open_label_cleanup` record the
|
||||
reconciliation tool returned — `clean` / `failed` / `not applicable (no linked
|
||||
issue)`, with the labels removed and preserved. Reconciliation is a terminal
|
||||
transition, so a non-`clean` record blocks any "reconciled" claim; recover with
|
||||
`gitea_cleanup_terminal_pr_labels` (`terminal_reason='retry_recovery'`) and
|
||||
confirm with `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread.
|
||||
@@ -99,6 +99,21 @@ Narrative final report and controller handoff must agree on eligibility class,
|
||||
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
|
||||
### Terminal label state (#780)
|
||||
|
||||
A run that takes a PR to a terminal state — merged, closed without merge,
|
||||
superseded, or reconciled as already landed — must report what happened to the
|
||||
linked issue's `status:pr-open` label, quoting the `pr_open_label_cleanup`
|
||||
record the terminal tool returned:
|
||||
|
||||
- Terminal label cleanup: `clean` / `failed` / `not applicable (no linked issue)`
|
||||
- Labels removed and preserved per issue, with the read-after-write read-back
|
||||
|
||||
Never claim the transition is complete while that record is not `clean`. A
|
||||
failed cleanup does not undo the merge; the safe next action is
|
||||
`gitea_cleanup_terminal_pr_labels` with `terminal_reason='retry_recovery'`,
|
||||
confirmed by `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
### Proof-backed claims (#395)
|
||||
|
||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
||||
@@ -116,4 +131,9 @@ or structured MCP metadata — not narrative alone:
|
||||
|
||||
When a claim relies on prior-session blocker state or MCP metadata only, label
|
||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread. A reviewer hands off to `merger`; a merger transitions to
|
||||
`merged-awaiting-controller` rather than declaring the work accepted.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Canonical self-propagating handoff schema (#626)
|
||||
|
||||
**Applies to:** every workflow actor — author, reviewer, merger, controller,
|
||||
operator, reconciler.
|
||||
|
||||
`#494`–`#507` defined the ledger, the canonical state comments, and the
|
||||
Canonical Thread Handoff shape. This schema owns the *chain*: each actor
|
||||
consumes exactly one canonical handoff, performs exactly one authorized role,
|
||||
records the result durably in Gitea, and emits the next complete handoff —
|
||||
until the controller records final closure.
|
||||
|
||||
Implemented and enforced by `self_propagating_handoff.py`; wired into
|
||||
`final_report_validator.py` as rule `shared.self_propagating_handoff`.
|
||||
|
||||
## The block
|
||||
|
||||
Post this block into the Gitea issue or PR thread, and include it verbatim in
|
||||
the final report. It is not an optional prose section.
|
||||
|
||||
```md
|
||||
<!-- sph:v1 -->
|
||||
## Canonical Handoff
|
||||
|
||||
```text
|
||||
REPOSITORY: <org>/<repo>
|
||||
ISSUE: <number>
|
||||
PR: <number or none>
|
||||
WORKFLOW_STATE: <one of the workflow states below>
|
||||
HEAD_SHA: <current head, or none before a branch exists>
|
||||
BASE_BRANCH: <base branch>
|
||||
BASE_OR_MERGE_SHA: <base SHA, or merge commit SHA after merge>
|
||||
ACTING_ROLE: <author|reviewer|merger|controller|operator|reconciler>
|
||||
ACTING_IDENTITY: <username (profile)>
|
||||
COMPLETED_ACTIONS: <what this actor actually did>
|
||||
VALIDATION_EVIDENCE: <commands run and their results>
|
||||
MUTATION_LEDGER: <every durable mutation performed>
|
||||
BLOCKERS: <active blockers, or none>
|
||||
NEXT_ACTOR: <role authorized by WORKFLOW_STATE, or none when complete>
|
||||
NEXT_ACTION: <exact next action, or none when complete>
|
||||
PROHIBITED_ACTIONS: <what the next actor must not do>
|
||||
NEXT_PROMPT: <complete ready-to-run prompt, or none when complete>
|
||||
WORKFLOW_FAILURE_ISSUES: <durable issue refs for tooling defects, or none>
|
||||
LAST_UPDATED: <UTC timestamp>
|
||||
```
|
||||
```
|
||||
|
||||
## Workflow states and the single authorized actor
|
||||
|
||||
| `WORKFLOW_STATE` | `NEXT_ACTOR` |
|
||||
| --------------------------- | ------------ |
|
||||
| `needs-author` | `author` |
|
||||
| `needs-review` | `reviewer` |
|
||||
| `approved-awaiting-merge` | `merger` |
|
||||
| `merged-awaiting-controller`| `controller` |
|
||||
| `blocked` | `operator` |
|
||||
| `complete` | `none` |
|
||||
|
||||
A merged PR is **not** accepted work: merge transitions to
|
||||
`merged-awaiting-controller` unless the configured workflow explicitly
|
||||
authorizes automatic acceptance.
|
||||
|
||||
## Fail-closed rules
|
||||
|
||||
* Every field is required. Only `PR`, `HEAD_SHA`, `BASE_OR_MERGE_SHA`,
|
||||
`BLOCKERS`, `WORKFLOW_FAILURE_ISSUES`, `NEXT_ACTION`, and `NEXT_PROMPT` may
|
||||
carry `none`, and `PR`/`HEAD_SHA` only in `needs-author` or `blocked`.
|
||||
* `NEXT_ACTOR` must equal the actor the declared state authorizes.
|
||||
* `blocked` requires a concrete `BLOCKERS` entry.
|
||||
* A non-terminal handoff requires a concrete `NEXT_ACTION` and a
|
||||
`NEXT_PROMPT` long enough to be ready to run.
|
||||
* `complete` must carry no `NEXT_ACTION` and no `NEXT_PROMPT`: a finished
|
||||
workflow terminates instead of manufacturing more work.
|
||||
* `NEXT_PROMPT` must name the repository and the issue, and must not depend on
|
||||
outside chat history. The issue or PR thread, workflow documentation, and
|
||||
live repository state must be sufficient to recover the task.
|
||||
* The handoff must be posted to Gitea. A chat-only report is not durable
|
||||
workflow state.
|
||||
|
||||
## Live-state recovery before acting
|
||||
|
||||
The receiving actor re-derives truth from live state instead of trusting the
|
||||
inherited handoff. `assess_handoff_live_state` detects and fails closed on:
|
||||
`changed_pr_head`, `stale_approval`, `issue_closed`, `issue_reopened`,
|
||||
`pr_merged`, `pr_closed_unmerged`, `stale_lease`, `foreign_lease`,
|
||||
`missing_worktree`, `dirty_worktree`, `namespace_mismatch`, `stale_runtime`,
|
||||
`changed_base`, and `conflicting_canonical_comments`.
|
||||
|
||||
A changed head invalidates any inherited review or merge handoff; the chain
|
||||
recovers to `needs-review`.
|
||||
|
||||
## Controller closure
|
||||
|
||||
`accept` is only honored with all four closure proofs recorded:
|
||||
`acceptance_criteria_satisfied`, `cleanup_complete`,
|
||||
`canonical_final_state_posted`, `issue_closed_through_workflow`. Otherwise the
|
||||
work item stays at `merged-awaiting-controller`.
|
||||
|
||||
`request_tests`, `request_proof`, `request_corrections`, and `reopen` return
|
||||
the work to the author; `return_to_actor` returns it to a named earlier actor.
|
||||
|
||||
## Workflow-failure escalation
|
||||
|
||||
Tooling or workflow defects found while working an item never get folded into
|
||||
the active feature issue. Each distinct failure carries `classification`,
|
||||
`linked_issue`, `temporary_impact`, `next_valid_actor`, and `recovery_prompt`.
|
||||
A failure whose signature already has a durable issue must reuse that issue
|
||||
instead of filing a duplicate.
|
||||
|
||||
## Applicability
|
||||
|
||||
Enforcement is applicability-gated exactly like the #495 canonical-state gate:
|
||||
once a report carries the `sph:v1` marker, the `Canonical Handoff` heading, or
|
||||
a `WORKFLOW_STATE:` line, the full schema is enforced and incomplete handoffs
|
||||
are rejected. Reports written before the protocol existed are unaffected.
|
||||
@@ -70,4 +70,9 @@ selected issue, and mutation ledger categories (#319, #320).
|
||||
`Read-only diagnostics` (#297).
|
||||
|
||||
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread. The next prompt is not an optional prose
|
||||
section.
|
||||
@@ -33,22 +33,34 @@ Steps:
|
||||
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||
2. Verify authenticated identity + active profile.
|
||||
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
|
||||
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
|
||||
- `merge_now` → continue merger path (do **not** update the branch)
|
||||
- `update_branch_by_merge` → STOP; hand off to **author** for
|
||||
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
|
||||
- `author_conflict_remediation` → STOP; author worktree conflict fix
|
||||
- `fresh_review_required` → STOP; independent re-review at current head
|
||||
- `blocked` → STOP and diagnose
|
||||
Never merge on a former-head approval after update/remediation.
|
||||
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||
output (or runtime context) proving merge_pr is allowed — a bare
|
||||
"capability checks passed" claim is downgraded.
|
||||
5. Final live-state recheck (#179), immediately before the merge mutation —
|
||||
6. Final live-state recheck (#179), immediately before the merge mutation —
|
||||
re-read the live PR and prove:
|
||||
- PR still open
|
||||
- live head SHA still equals the pinned/reviewed head SHA
|
||||
- base branch unchanged
|
||||
- no undismissed REQUEST_CHANGES / blocking review state remains
|
||||
If any recheck fails → STOP, re-pin, re-validate.
|
||||
6. If any gate fails → STOP and report.
|
||||
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||
7. If any gate fails → STOP and report.
|
||||
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
||||
the changed-file set.
|
||||
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
|
||||
cleanup handoff, then reassess the **next** PR (do not batch-update all
|
||||
open PRs).
|
||||
|
||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||
|
||||
@@ -450,6 +450,35 @@ If any gate fails, do not create the issue.
|
||||
|
||||
Produce a recovery handoff or duplicate report.
|
||||
|
||||
### 18a. Sanctioned first-mutation path (#749)
|
||||
|
||||
`gitea_create_issue` is a **pure remote mutation** (no local tree write). The
|
||||
issue-first gate forbids creating `branches/issue-<N>-*` before the issue
|
||||
number exists. Therefore the **only sanctioned first mutation** is:
|
||||
|
||||
1. Read-only identity + capability + duplicate search from the control checkout.
|
||||
2. Ensure the **canonical control checkout** is:
|
||||
* the configured repository root for the requested remote/org/repo;
|
||||
* on an accepted base branch (`master` / `main` / `dev`);
|
||||
* base-equivalent to live master;
|
||||
* clean (no tracked local edits);
|
||||
* in runtime/master parity.
|
||||
3. Resolve exact task `create_issue`, then call `gitea_create_issue` **from that
|
||||
clean control checkout** (no `worktree_path` required for this step alone).
|
||||
4. After the issue number exists: create a **registered** worktree under
|
||||
`branches/issue-<N>-*`, claim/lock, and perform every subsequent author
|
||||
mutation from that worktree only.
|
||||
|
||||
**Forbidden improvisations (fail closed):**
|
||||
|
||||
* `mkdir` dummy directories under `branches/` (#713)
|
||||
* borrowing an unrelated pre-existing worktree
|
||||
* creating a pre-issue worktree in violation of issue-first
|
||||
* running create_issue from a dirty, drifted, detached, or non-canonical root
|
||||
|
||||
Post-creation mutations (`lock_issue`, commit, push, `create_pr`, etc.) **never**
|
||||
receive this bootstrap exemption.
|
||||
|
||||
## 19. Issue commenting gate
|
||||
|
||||
Before commenting on an existing issue, verify:
|
||||
|
||||
@@ -950,9 +950,53 @@ Final reports must state:
|
||||
* final live head SHA before merge
|
||||
* whether any push occurred during validation
|
||||
|
||||
## 26D. PR synchronization and conflict-remediation lifecycle
|
||||
|
||||
**Do not treat “approved” as the final readiness state.** For every approved
|
||||
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
|
||||
`recommended_next_action`:
|
||||
|
||||
| Action | Meaning | Next role / tools |
|
||||
|--------|---------|-------------------|
|
||||
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
|
||||
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
|
||||
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
|
||||
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
|
||||
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
|
||||
|
||||
### Hard rules
|
||||
|
||||
* Pin both expected PR head and expected base head for every update. Either
|
||||
race → fail closed with no partial mutation.
|
||||
* Never rebase or force-push author branches.
|
||||
* Never update an author branch from a reviewer or merger profile.
|
||||
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
|
||||
across a head change.
|
||||
* Process PRs **sequentially**: synchronize/remediate one → review new head →
|
||||
merge → refresh live `master` → reassess the next PR. Do not update every
|
||||
open PR at once (each merge restales the rest).
|
||||
* Conflict remediation only in the existing dedicated issue/PR worktree under
|
||||
`branches/`. Do not delete that worktree until the updated PR is merged and
|
||||
cleanup eligibility is proven.
|
||||
* Post-merge: canonical cleanup handoff to reconciler (section 28).
|
||||
|
||||
### After `gitea_update_pr_branch_by_merge` succeeds
|
||||
|
||||
1. Treat former-head approval as invalidated.
|
||||
2. Release/supersede obsolete reviewer and merger leases.
|
||||
3. Route `recommended_next_action=fresh_review_required` at the new head.
|
||||
4. Independent re-review, then merger lease/adopt + merge at the new head only.
|
||||
|
||||
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
|
||||
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
|
||||
access.
|
||||
|
||||
## 27. Merge rules
|
||||
|
||||
Before merge, rerun fresh live checks:
|
||||
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
|
||||
and may be behind base. Only proceed with merge when
|
||||
`recommended_next_action` is `merge_now` and approval remains at the current
|
||||
head. Then rerun fresh live checks:
|
||||
|
||||
* whoami
|
||||
* active profile/runtime
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Stable-control vs dev/test runtime classification and mutation gates (#615).
|
||||
|
||||
``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
|
||||
real Gitea mutations may only be performed by the **stable control runtime**,
|
||||
while MCP server development happens in isolated ``branches/`` worktrees and
|
||||
optional dev/test runtimes. The ADR alone is not enforcement — a daemon
|
||||
relaunched from a feature worktree still holds production credentials and will
|
||||
happily mutate production issues.
|
||||
|
||||
This module supplies the runtime half of that policy:
|
||||
|
||||
* :func:`classify_runtime_mode` decides whether the running process is a
|
||||
``stable-control``, ``dev-test``, or ``unknown`` runtime.
|
||||
* :func:`build_runtime_report` collects the reporting fields the ADR requires
|
||||
(mode, SHA, branch, checkout path, process root, workspace, binding, dirty
|
||||
files, alignment, and whether real mutations are allowed).
|
||||
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
|
||||
mutation gate.
|
||||
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
|
||||
so proving the author namespace never implies the reviewer, merger, or
|
||||
reconciler namespace is callable.
|
||||
|
||||
Every assessment is pure: callers inject the observed facts, so the logic is
|
||||
unit-testable without a git checkout or a live daemon. Only the thin
|
||||
:func:`observe_runtime` reader touches the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Operator declaration of the runtime this process is. An explicit, valid
|
||||
# declaration wins over inference — an operator running a packaged release
|
||||
# layout may have no git checkout to infer from.
|
||||
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
|
||||
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"
|
||||
|
||||
RUNTIME_MODE_STABLE = "stable-control"
|
||||
RUNTIME_MODE_DEV_TEST = "dev-test"
|
||||
RUNTIME_MODE_UNKNOWN = "unknown"
|
||||
|
||||
VALID_RUNTIME_MODES = frozenset(
|
||||
{RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
|
||||
)
|
||||
|
||||
# Branches a stable control checkout is allowed to sit on. Anything else is a
|
||||
# development checkout by definition (the global worktree rule keeps the
|
||||
# control checkout on a stable branch).
|
||||
STABLE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
# Path segment that marks an isolated development worktree.
|
||||
DEV_WORKTREE_SEGMENT = "branches"
|
||||
|
||||
BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
|
||||
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
|
||||
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
|
||||
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
|
||||
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
|
||||
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"
|
||||
|
||||
# Namespaces that must each be re-proven independently after a transport flap.
|
||||
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")
|
||||
|
||||
|
||||
def gate_disabled() -> bool:
|
||||
"""Whether the runtime-mode gate is disabled by env escape hatch."""
|
||||
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||
|
||||
|
||||
def declared_runtime_mode() -> str | None:
|
||||
"""Return the operator-declared runtime mode, if a valid one is set.
|
||||
|
||||
An unset or unrecognised value returns ``None`` so classification falls
|
||||
back to inference rather than trusting a typo.
|
||||
"""
|
||||
value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
|
||||
if value in VALID_RUNTIME_MODES:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _path_segments(path: str) -> list[str]:
|
||||
return [seg for seg in os.path.normpath(path).split(os.sep) if seg]
|
||||
|
||||
|
||||
def launched_from_dev_worktree(process_root: str | None) -> bool:
|
||||
"""Whether *process_root* sits inside a ``branches/`` development worktree."""
|
||||
if not process_root:
|
||||
return False
|
||||
return DEV_WORKTREE_SEGMENT in _path_segments(process_root)
|
||||
|
||||
|
||||
def classify_runtime_mode(
|
||||
*,
|
||||
process_root: str | None,
|
||||
checkout_branch: str | None,
|
||||
is_git_checkout: bool = True,
|
||||
declared_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Classify the runtime this process is serving from.
|
||||
|
||||
``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
|
||||
when supplied and valid. Otherwise the mode is inferred:
|
||||
|
||||
* no resolvable root, or a root that is not a git checkout → ``unknown``
|
||||
(a packaged deployment must declare its mode explicitly);
|
||||
* a root inside a ``branches/`` worktree → ``dev-test``;
|
||||
* an unreadable branch → ``unknown``;
|
||||
* a stable branch (``master``/``main``/``dev``) → ``stable-control``;
|
||||
* any other branch → ``dev-test``.
|
||||
|
||||
Returns the mode plus the discriminating facts and human-readable reasons.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
dev_worktree = launched_from_dev_worktree(process_root)
|
||||
|
||||
if declared_mode in VALID_RUNTIME_MODES:
|
||||
reasons.append(
|
||||
f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
|
||||
f"{declared_mode}"
|
||||
)
|
||||
return _mode_result(declared_mode, dev_worktree, True, reasons)
|
||||
|
||||
if not process_root:
|
||||
reasons.append(
|
||||
"runtime process root could not be resolved; runtime mode is "
|
||||
"indeterminate"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||
|
||||
if not is_git_checkout:
|
||||
reasons.append(
|
||||
f"runtime process root '{process_root}' is not a git checkout and "
|
||||
f"no {ENV_RUNTIME_MODE} declaration was supplied"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||
|
||||
if dev_worktree:
|
||||
reasons.append(
|
||||
f"runtime was launched from development worktree '{process_root}' "
|
||||
f"(inside '{DEV_WORKTREE_SEGMENT}/')"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)
|
||||
|
||||
branch = (checkout_branch or "").strip()
|
||||
if not branch:
|
||||
reasons.append(
|
||||
f"runtime checkout branch at '{process_root}' could not be read "
|
||||
f"(detached HEAD or unreadable); runtime mode is indeterminate"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)
|
||||
|
||||
if branch in STABLE_BRANCHES:
|
||||
reasons.append(
|
||||
f"runtime checkout '{process_root}' is on stable branch '{branch}'"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)
|
||||
|
||||
reasons.append(
|
||||
f"runtime checkout '{process_root}' is on development branch "
|
||||
f"'{branch}', not a stable branch "
|
||||
f"({', '.join(sorted(STABLE_BRANCHES))})"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)
|
||||
|
||||
|
||||
def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
|
||||
return {
|
||||
"runtime_mode": mode,
|
||||
"dev_worktree_launched": bool(dev_worktree),
|
||||
"declared": bool(declared),
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_report(
|
||||
*,
|
||||
process_root: str | None,
|
||||
checkout_branch: str | None,
|
||||
runtime_head: str | None,
|
||||
active_task_workspace: str | None = None,
|
||||
canonical_repository_root: str | None = None,
|
||||
repository_slug: str | None = None,
|
||||
profile: str | None = None,
|
||||
authenticated_identity: str | None = None,
|
||||
dirty_files: list[str] | tuple[str, ...] | None = None,
|
||||
workspace_roots_aligned: bool | None = None,
|
||||
is_git_checkout: bool = True,
|
||||
declared_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Build the ADR-required runtime report (#615 acceptance criterion 6).
|
||||
|
||||
``real_mutations_allowed`` is the summary bit: it is true only when the
|
||||
corresponding mutation gate finds nothing to block on for a production
|
||||
target.
|
||||
"""
|
||||
classification = classify_runtime_mode(
|
||||
process_root=process_root,
|
||||
checkout_branch=checkout_branch,
|
||||
is_git_checkout=is_git_checkout,
|
||||
declared_mode=declared_mode,
|
||||
)
|
||||
report = {
|
||||
"runtime_mode": classification["runtime_mode"],
|
||||
"runtime_mode_declared": classification["declared"],
|
||||
"runtime_mode_reasons": classification["reasons"],
|
||||
"dev_worktree_launched": classification["dev_worktree_launched"],
|
||||
"runtime_git_sha": runtime_head,
|
||||
"runtime_branch": checkout_branch,
|
||||
"runtime_checkout_path": process_root,
|
||||
"mcp_process_root": process_root,
|
||||
"active_task_workspace": active_task_workspace,
|
||||
"canonical_repository_root": canonical_repository_root,
|
||||
"repository_slug": repository_slug,
|
||||
"profile": profile,
|
||||
"authenticated_identity": authenticated_identity,
|
||||
"dirty_files": sorted(dirty_files or []),
|
||||
"workspace_roots_aligned": workspace_roots_aligned,
|
||||
"gate_enforced": not gate_disabled(),
|
||||
}
|
||||
gate = assess_runtime_mutation_gate(report)
|
||||
report["real_mutations_allowed"] = not gate["block"]
|
||||
report["mutation_block_reasons"] = gate["reasons"]
|
||||
return report
|
||||
|
||||
|
||||
def assess_runtime_mutation_gate(
|
||||
report: dict,
|
||||
*,
|
||||
target_is_production: bool = True,
|
||||
namespace: str | None = None,
|
||||
namespace_reproof: dict | None = None,
|
||||
) -> dict:
|
||||
"""Fail-closed mutation gate for the runtime a mutation would execute in.
|
||||
|
||||
Blocks when (acceptance criterion 7):
|
||||
|
||||
* the runtime is ``dev-test`` and the mutation targets the production
|
||||
repository;
|
||||
* the runtime mode is ``unknown``;
|
||||
* the stable runtime checkout is dirty;
|
||||
* the runtime was launched from a development worktree;
|
||||
* process-root / workspace alignment is unsafe;
|
||||
* (criterion 8) the namespace has not been re-proven since a transport flap.
|
||||
|
||||
The disabled escape hatch never blocks; the caller decides read-vs-mutate
|
||||
before calling.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
blockers: list[str] = []
|
||||
|
||||
if gate_disabled():
|
||||
return _gate_result(False, blockers, reasons, disabled=True)
|
||||
|
||||
mode = (report or {}).get("runtime_mode")
|
||||
|
||||
if mode == RUNTIME_MODE_UNKNOWN:
|
||||
blockers.append(BLOCKER_UNKNOWN_RUNTIME)
|
||||
reasons.append(
|
||||
"runtime mode is 'unknown'; a runtime that cannot prove it is the "
|
||||
f"stable control runtime must not mutate production (declare "
|
||||
f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
|
||||
)
|
||||
|
||||
if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
|
||||
blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
|
||||
reasons.append(
|
||||
"runtime mode is 'dev-test' and the mutation targets the "
|
||||
"production repository; dev/test runtimes must not mutate real "
|
||||
"issues or PRs (ADR: stable control runtime vs dev runtime)"
|
||||
)
|
||||
|
||||
if report.get("dev_worktree_launched") and target_is_production:
|
||||
blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
|
||||
reasons.append(
|
||||
f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
|
||||
f"worktree ('{report.get('mcp_process_root')}'); production "
|
||||
f"mutations require the promoted stable control runtime"
|
||||
)
|
||||
|
||||
dirty = list(report.get("dirty_files") or [])
|
||||
if mode == RUNTIME_MODE_STABLE and dirty:
|
||||
blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
|
||||
reasons.append(
|
||||
"stable control runtime checkout is dirty "
|
||||
f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
|
||||
f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
|
||||
"promoted, unmodified code"
|
||||
)
|
||||
|
||||
if report.get("workspace_roots_aligned") is False:
|
||||
blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
|
||||
reasons.append(
|
||||
"process-root / active-workspace alignment is unsafe; the runtime "
|
||||
"and the task workspace disagree about which checkout is being "
|
||||
"mutated"
|
||||
)
|
||||
|
||||
if namespace:
|
||||
reproof = assess_namespace_reproof(namespace_reproof, namespace)
|
||||
if reproof["reproof_required"] and not reproof["proven"]:
|
||||
blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
|
||||
reasons.extend(reproof["reasons"])
|
||||
|
||||
return _gate_result(bool(blockers), blockers, reasons)
|
||||
|
||||
|
||||
def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
|
||||
return {
|
||||
"block": bool(block),
|
||||
"blocker_kinds": list(blockers),
|
||||
"blocker_kind": blockers[0] if blockers else None,
|
||||
"reasons": list(reasons),
|
||||
"gate_disabled": bool(disabled),
|
||||
}
|
||||
|
||||
|
||||
def runtime_block_reasons(
|
||||
report: dict,
|
||||
*,
|
||||
target_is_production: bool = True,
|
||||
namespace: str | None = None,
|
||||
namespace_reproof: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed)."""
|
||||
gate = assess_runtime_mutation_gate(
|
||||
report,
|
||||
target_is_production=target_is_production,
|
||||
namespace=namespace,
|
||||
namespace_reproof=namespace_reproof,
|
||||
)
|
||||
return gate["reasons"]
|
||||
|
||||
|
||||
def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
|
||||
"""Structured recovery payload for permission-block responses."""
|
||||
gate = gate or assess_runtime_mutation_gate(report)
|
||||
return {
|
||||
"kind": "runtime_mode_block",
|
||||
"runtime_mode": report.get("runtime_mode"),
|
||||
"runtime_git_sha": report.get("runtime_git_sha"),
|
||||
"runtime_branch": report.get("runtime_branch"),
|
||||
"runtime_checkout_path": report.get("runtime_checkout_path"),
|
||||
"blocker_kind": gate.get("blocker_kind"),
|
||||
"blocker_kinds": list(gate.get("blocker_kinds") or []),
|
||||
"reasons": list(gate.get("reasons") or []),
|
||||
"recovery": [
|
||||
"Real workflow mutations run only on the promoted stable control "
|
||||
"runtime (see docs/architecture/"
|
||||
"mcp-stable-control-runtime-policy-adr.md).",
|
||||
"Operator action: promote the intended revision into the stable "
|
||||
"runtime and reload it — see "
|
||||
"docs/stable-runtime-promotion-runbook.md.",
|
||||
"Normal author/reviewer/merger/reconciler sessions must not kill, "
|
||||
"restart, or relaunch the MCP server themselves.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_runtime_mode(report: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
|
||||
sha = report.get("runtime_git_sha")
|
||||
branch = report.get("runtime_branch") or "unknown-branch"
|
||||
short = sha[:12] if sha else "unknown-sha"
|
||||
suffix = (
|
||||
"" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
|
||||
)
|
||||
return f"{mode} at {short} on {branch}{suffix}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
|
||||
#
|
||||
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
|
||||
# author namespace afterwards says nothing about the reviewer, merger, or
|
||||
# reconciler namespace, so proof is tracked per namespace and a flap
|
||||
# invalidates all of them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUIRED_NAMESPACE_PROOF_STEPS = (
|
||||
"whoami",
|
||||
"runtime_context",
|
||||
"capability_resolved",
|
||||
)
|
||||
|
||||
|
||||
def new_reproof_state() -> dict:
|
||||
"""Return an empty post-flap re-proving state."""
|
||||
return {"flap_at": None, "namespaces": {}}
|
||||
|
||||
|
||||
def record_transport_flap(state: dict | None, *, at: str) -> dict:
|
||||
"""Record a transport flap: every namespace must be re-proven after *at*.
|
||||
|
||||
Existing per-namespace proofs are kept for audit but no longer satisfy the
|
||||
gate, because they were recorded before the flap.
|
||||
"""
|
||||
result = dict(state or new_reproof_state())
|
||||
result["flap_at"] = at
|
||||
result["namespaces"] = dict(result.get("namespaces") or {})
|
||||
return result
|
||||
|
||||
|
||||
def record_namespace_proof(
|
||||
state: dict | None,
|
||||
namespace: str,
|
||||
*,
|
||||
at: str,
|
||||
whoami: bool = False,
|
||||
runtime_context: bool = False,
|
||||
capability_resolved: bool = False,
|
||||
stale_runtime_reported: bool = False,
|
||||
) -> dict:
|
||||
"""Record proof steps completed for exactly one namespace.
|
||||
|
||||
A namespace whose proof reported a reconnect/restart/stale-runtime gate is
|
||||
never counted as proven, regardless of which steps ran.
|
||||
"""
|
||||
result = dict(state or new_reproof_state())
|
||||
namespaces = dict(result.get("namespaces") or {})
|
||||
namespaces[(namespace or "").strip()] = {
|
||||
"at": at,
|
||||
"whoami": bool(whoami),
|
||||
"runtime_context": bool(runtime_context),
|
||||
"capability_resolved": bool(capability_resolved),
|
||||
"stale_runtime_reported": bool(stale_runtime_reported),
|
||||
}
|
||||
result["namespaces"] = namespaces
|
||||
return result
|
||||
|
||||
|
||||
def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
|
||||
"""Whether *namespace* is re-proven after the most recent transport flap.
|
||||
|
||||
``reproof_required`` is false when no flap has been recorded — this gate
|
||||
only speaks to post-flap proof and never invents a requirement.
|
||||
"""
|
||||
ns = (namespace or "").strip()
|
||||
store = state or {}
|
||||
flap_at = store.get("flap_at")
|
||||
reasons: list[str] = []
|
||||
|
||||
if not flap_at:
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": False,
|
||||
"proven": True,
|
||||
"flap_at": None,
|
||||
"proof_at": None,
|
||||
"missing_steps": [],
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
entry = (store.get("namespaces") or {}).get(ns)
|
||||
if not entry:
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' has not been re-proven since the transport "
|
||||
f"flap at {flap_at}; run whoami, runtime context, and capability "
|
||||
f"resolve for '{ns}' itself (proof of another namespace does not "
|
||||
f"transfer)"
|
||||
)
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": False,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": None,
|
||||
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
proof_at = entry.get("at")
|
||||
if proof_at is not None and str(proof_at) < str(flap_at):
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
|
||||
f"flap at {flap_at}; re-prove the namespace before mutating"
|
||||
)
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": False,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": proof_at,
|
||||
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
|
||||
if missing:
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
|
||||
f"{', '.join(missing)}"
|
||||
)
|
||||
if entry.get("stale_runtime_reported"):
|
||||
missing = missing or ["stale_runtime_clear"]
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
|
||||
f"gate during re-proving; mutation stays blocked until the "
|
||||
f"namespace reconnects cleanly"
|
||||
)
|
||||
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": not missing,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": proof_at,
|
||||
"missing_steps": list(missing),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def unproven_namespaces(
|
||||
state: dict | None, namespaces=WORKFLOW_NAMESPACES
|
||||
) -> list[str]:
|
||||
"""Return the namespaces still requiring post-flap re-proving."""
|
||||
out = []
|
||||
for ns in namespaces:
|
||||
assessment = assess_namespace_reproof(state, ns)
|
||||
if assessment["reproof_required"] and not assessment["proven"]:
|
||||
out.append(ns)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Promotion records (#615 acceptance criterion 4 / 10)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMOTION_REQUIRED_FIELDS = (
|
||||
"previous_runtime_sha",
|
||||
"promoted_runtime_sha",
|
||||
"source_branch",
|
||||
"source_pr",
|
||||
"restart_method",
|
||||
"health_check_proof",
|
||||
"identity_proof",
|
||||
"profile_proof",
|
||||
"workspace_proof",
|
||||
"mutation_capability_proof",
|
||||
"rollback_instructions",
|
||||
)
|
||||
|
||||
|
||||
def assess_promotion_record(record: dict | None) -> dict:
|
||||
"""Validate an operator promotion record against the ADR checklist.
|
||||
|
||||
A promotion that does not record both the previous and the promoted SHA is
|
||||
not a promotion — it is an undocumented restart.
|
||||
"""
|
||||
data = record or {}
|
||||
missing = [
|
||||
field
|
||||
for field in PROMOTION_REQUIRED_FIELDS
|
||||
if not str(data.get(field) or "").strip()
|
||||
]
|
||||
reasons = []
|
||||
if missing:
|
||||
reasons.append(
|
||||
"promotion record is incomplete; missing: " + ", ".join(missing)
|
||||
)
|
||||
previous = str(data.get("previous_runtime_sha") or "").strip()
|
||||
promoted = str(data.get("promoted_runtime_sha") or "").strip()
|
||||
if previous and promoted and previous == promoted:
|
||||
reasons.append(
|
||||
"promotion record lists the same previous and promoted SHA "
|
||||
f"({previous[:12]}); nothing was promoted"
|
||||
)
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"missing_fields": missing,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem observation (the only impure helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _git_capture(root: str, *args: str) -> str | None:
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def observe_dirty_files(process_root: str | None) -> list[str]:
|
||||
"""Read the live dirty-file list at *process_root*.
|
||||
|
||||
Split out from :func:`observe_runtime` because dirtiness is the one runtime
|
||||
fact that legitimately changes during a process lifetime. The mutation gate
|
||||
must re-read it per call rather than trust a startup snapshot, or a checkout
|
||||
that goes dirty after the snapshot is never blocked again (#615).
|
||||
"""
|
||||
if not process_root:
|
||||
return []
|
||||
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
|
||||
return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def observe_runtime(process_root: str | None) -> dict:
|
||||
"""Read the runtime facts classification needs from *process_root*.
|
||||
|
||||
Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
|
||||
``dirty_files``. Every read failure degrades to ``None``/empty rather than
|
||||
raising, so a runtime that cannot be inspected classifies as ``unknown``
|
||||
instead of crashing the caller.
|
||||
"""
|
||||
empty = {
|
||||
"checkout_branch": None,
|
||||
"runtime_head": None,
|
||||
"is_git_checkout": False,
|
||||
"dirty_files": [],
|
||||
}
|
||||
if not process_root:
|
||||
return empty
|
||||
if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
|
||||
return empty
|
||||
branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if branch == "HEAD": # detached HEAD has no branch name
|
||||
branch = None
|
||||
dirty = observe_dirty_files(process_root)
|
||||
return {
|
||||
"checkout_branch": branch,
|
||||
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
|
||||
"is_git_checkout": True,
|
||||
"dirty_files": dirty,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Sanctioned recovery for stale env-bound task workspace bindings (#702).
|
||||
|
||||
When the MCP daemon dies from an unhandled exception it never runs teardown,
|
||||
and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its
|
||||
parent environment. That inherited value can permanently bind the fresh
|
||||
session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident).
|
||||
|
||||
This module classifies the active env binding against live session evidence
|
||||
and produces a fail-closed recovery plan. Only two situations permit the
|
||||
daemon to clear the binding on its own:
|
||||
|
||||
- the bound path no longer exists on disk (``provably_stale_missing_path``)
|
||||
- the binding was inherited at daemon boot and a *sanctioned* in-session
|
||||
reviewer/merger lease later bound a different worktree
|
||||
(``superseded_by_session_lease``)
|
||||
|
||||
Everything else is surfaced (``unverified_inherited``) and left for the
|
||||
managed reconnect path — never cleared silently, never rebound to a guessed
|
||||
worktree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
|
||||
CLASSIFICATION_UNBOUND = "unbound"
|
||||
CLASSIFICATION_CORROBORATED = "corroborated"
|
||||
CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited"
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path"
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease"
|
||||
|
||||
CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_UNBOUND,
|
||||
CLASSIFICATION_CORROBORATED,
|
||||
CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
})
|
||||
|
||||
# Roles whose task workspace is owned by a lease lifecycle, so an inherited
|
||||
# generic binding without a corroborating lease is suspect.
|
||||
LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"})
|
||||
|
||||
RECOVERY_ACTION_NONE = "none"
|
||||
RECOVERY_ACTION_CLEAR_ENV = "clear_env"
|
||||
|
||||
|
||||
def _norm(path: str | None) -> str:
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return os.path.realpath(os.path.abspath(text))
|
||||
|
||||
|
||||
def snapshot_boot_bindings(
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture worktree env bindings present when the daemon booted.
|
||||
|
||||
A value present in this snapshot was inherited from the parent
|
||||
environment, not established by any sanctioned tool in this daemon's
|
||||
lifetime.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
return {
|
||||
"active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def classify_active_worktree_binding(
|
||||
*,
|
||||
active_value: str | None,
|
||||
role_env_value: str | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
boot_inherited: bool,
|
||||
path_exists: bool | None,
|
||||
role_kind: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify the GITEA_ACTIVE_WORKTREE binding against live evidence.
|
||||
|
||||
Fail closed: unknown evidence never yields a clear-eligible class.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
active = _norm(active_value)
|
||||
if not active:
|
||||
return {
|
||||
"classification": CLASSIFICATION_UNBOUND,
|
||||
"clear_eligible": False,
|
||||
"active_worktree": None,
|
||||
"reasons": ["no GITEA_ACTIVE_WORKTREE binding present"],
|
||||
}
|
||||
|
||||
role = (role_kind or "").strip().lower()
|
||||
role_env = _norm(role_env_value)
|
||||
lease_wt = _norm(session_lease_worktree)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"active_worktree": active,
|
||||
"boot_inherited": bool(boot_inherited),
|
||||
"role_kind": role or None,
|
||||
"session_lease_worktree": lease_wt or None,
|
||||
"role_env_worktree": role_env or None,
|
||||
}
|
||||
|
||||
if path_exists is False:
|
||||
reasons.append(
|
||||
f"bound worktree '{active}' no longer exists on disk; the binding "
|
||||
"cannot name a valid task workspace"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
"clear_eligible": True,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt == active:
|
||||
reasons.append("binding matches the sanctioned in-session lease worktree")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt != active and boot_inherited:
|
||||
reasons.append(
|
||||
"boot-inherited binding disagrees with the sanctioned in-session "
|
||||
f"lease worktree '{lease_wt}'; the lease is live authority"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
"clear_eligible": True,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt != active and not boot_inherited:
|
||||
# Set after boot by tooling; disagreement is surfaced, never auto-cleared.
|
||||
reasons.append(
|
||||
"post-boot binding disagrees with the in-session lease worktree; "
|
||||
"surfacing only (fail closed, no automatic clear)"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if role_env and role_env == active:
|
||||
reasons.append("binding matches the role-specific worktree env binding")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt:
|
||||
reasons.append(
|
||||
"binding was inherited at daemon boot, no sanctioned in-session "
|
||||
f"lease corroborates it, and the {role} role binds workspaces "
|
||||
"through the lease lifecycle; treat as unverified until a fresh "
|
||||
"lease or managed reconnect re-establishes the workspace"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
reasons.append("no contradicting evidence; binding treated as corroborated")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn a classification into an explicit, fail-closed recovery plan."""
|
||||
classification = classification_result.get("classification")
|
||||
clear_eligible = bool(classification_result.get("clear_eligible"))
|
||||
reasons = list(classification_result.get("reasons") or [])
|
||||
|
||||
if classification not in CLASSIFICATIONS:
|
||||
return {
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"clear_allowed": False,
|
||||
"classification": classification,
|
||||
"reasons": reasons + ["unknown classification (fail closed)"],
|
||||
}
|
||||
|
||||
if clear_eligible and classification in {
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
}:
|
||||
return {
|
||||
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||
"clear_allowed": True,
|
||||
"classification": classification,
|
||||
"active_worktree": classification_result.get("active_worktree"),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
exact_next_action = None
|
||||
if classification == CLASSIFICATION_UNVERIFIED_INHERITED:
|
||||
exact_next_action = (
|
||||
"managed recovery required: reconnect the MCP namespace through "
|
||||
"the managed client configuration (or start a fresh session) so "
|
||||
"the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or "
|
||||
"corroborate the binding by acquiring a lease for that worktree; "
|
||||
"do not clear or rewrite the environment manually"
|
||||
)
|
||||
return {
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"clear_allowed": False,
|
||||
"classification": classification,
|
||||
"active_worktree": classification_result.get("active_worktree"),
|
||||
"reasons": reasons,
|
||||
**({"exact_next_action": exact_next_action} if exact_next_action else {}),
|
||||
}
|
||||
|
||||
|
||||
def apply_recovery(
|
||||
plan: dict[str, Any],
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a clear-eligible plan by removing the stale env binding.
|
||||
|
||||
This is the sanctioned in-daemon mechanism (#702 AC2); callers must
|
||||
persist the returned record for audit. Refuses anything but an explicit
|
||||
``clear_env`` plan.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV:
|
||||
return {
|
||||
"performed": False,
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"reasons": ["recovery plan does not authorize clearing (fail closed)"],
|
||||
}
|
||||
cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||
env_map.pop(ACTIVE_WORKTREE_ENV, None)
|
||||
return {
|
||||
"performed": True,
|
||||
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||
"cleared_env": ACTIVE_WORKTREE_ENV,
|
||||
"cleared_value": cleared_value,
|
||||
"classification": plan.get("classification"),
|
||||
"reasons": list(plan.get("reasons") or []),
|
||||
}
|
||||
+457
-13
@@ -1,4 +1,4 @@
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
||||
|
||||
#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
|
||||
@@ -12,6 +12,11 @@ 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.
|
||||
|
||||
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
|
||||
must not block a fresh formal decision on open PR B on the same durable
|
||||
profile lock (cross-PR isolation). Correction authorization is scoped to the
|
||||
named prior review's PR/head and cannot unlock a different PR.
|
||||
|
||||
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.
|
||||
@@ -80,17 +85,45 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||
return terminals[-1] if terminals else None
|
||||
|
||||
|
||||
def correction_applies(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> bool:
|
||||
"""True when operator correction is active and scoped to the target (#693).
|
||||
|
||||
Global ``correction_authorized`` alone is not enough: correction must name
|
||||
the same PR (and head when recorded) as the decision being re-opened.
|
||||
Missing scope fields on legacy locks fail closed (do not apply).
|
||||
"""
|
||||
if not lock or not lock.get("correction_authorized"):
|
||||
return False
|
||||
corr_pr = lock.get("correction_pr_number")
|
||||
if corr_pr is None:
|
||||
# Legacy unscoped correction — refuse as generic unlock (#693).
|
||||
return False
|
||||
if pr_number is not None and int(corr_pr) != int(pr_number):
|
||||
return False
|
||||
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if corr_head and target and not heads_equal(corr_head, target):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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).
|
||||
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
||||
|
||||
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).
|
||||
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
||||
Same PR + same head (or same PR without a comparable head SHA) blocks
|
||||
unless a scoped correction applies.
|
||||
|
||||
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||
@@ -98,7 +131,9 @@ def prior_live_mutations_block_boundary(
|
||||
"""
|
||||
if not lock:
|
||||
return False
|
||||
if lock.get("correction_authorized"):
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
return False
|
||||
prior = list(lock.get("live_mutations") or [])
|
||||
if not prior:
|
||||
@@ -108,10 +143,14 @@ def prior_live_mutations_block_boundary(
|
||||
if not isinstance(m, dict):
|
||||
return True
|
||||
m_pr = m.get("pr_number")
|
||||
if m_pr is None:
|
||||
return True # fail closed — unscoped mutation
|
||||
if int(m_pr) != int(pr_number):
|
||||
# #693: foreign-PR history on the shared durable lock does not block.
|
||||
continue
|
||||
m_head = mutation_head_sha(m, lock)
|
||||
if (
|
||||
m_pr == pr_number
|
||||
and target
|
||||
target
|
||||
and m_head
|
||||
and not heads_equal(m_head, target)
|
||||
):
|
||||
@@ -128,23 +167,34 @@ def terminal_boundary_allows_fresh_decision(
|
||||
expected_head_sha: str | None,
|
||||
operation: str,
|
||||
) -> bool:
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
||||
|
||||
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).
|
||||
For ``mark_ready`` / ``review`` / ``resume``:
|
||||
|
||||
* **same PR, different head** — allowed (#620)
|
||||
* **different PR than last terminal** — allowed (#693 cross-PR isolation)
|
||||
* **same PR, same head** — not allowed (unless scoped correction)
|
||||
|
||||
Merge is never reopened by head change or cross-PR isolation (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"):
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
return True
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
return True
|
||||
if last.get("pr_number") != pr_number:
|
||||
last_pr = last.get("pr_number")
|
||||
if last_pr is None:
|
||||
return False
|
||||
if int(last_pr) != int(pr_number):
|
||||
# #693: last terminal belongs to another PR — do not hard-stop this PR.
|
||||
return True
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if not locked_head or not target:
|
||||
@@ -440,6 +490,400 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- #693 classification / recovery / correction audit -----------------------
|
||||
|
||||
# Deterministic classification labels for diagnostics and recovery routing.
|
||||
CLASS_NO_LOCK = "no_lock"
|
||||
CLASS_READY_FOR_TARGET = "ready_for_target"
|
||||
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
|
||||
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
|
||||
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
|
||||
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
|
||||
CLASS_CORRECTION_ACTIVE = "correction_active"
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
|
||||
CLASS_AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
def classify_review_decision_lock(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int | None = None,
|
||||
target_head_sha: str | None = None,
|
||||
pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministic review-decision-lock classification (#693).
|
||||
|
||||
Returns classification, exact next_action, owner/evidence fields, and
|
||||
whether mark_final / cleanup / correction are appropriate.
|
||||
"""
|
||||
summary = lock_summary(lock)
|
||||
target_head = normalize_head_sha(target_head_sha)
|
||||
result: dict[str, Any] = {
|
||||
"classification": CLASS_NO_LOCK,
|
||||
"has_lock": lock is not None,
|
||||
"lock_summary": summary,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": target_head,
|
||||
"last_terminal_pr": None,
|
||||
"last_terminal_action": None,
|
||||
"locked_head_sha": None,
|
||||
"owner_profile_identity": (summary or {}).get("profile_identity"),
|
||||
"session_profile": (summary or {}).get("session_profile"),
|
||||
"updated_at": (summary or {}).get("updated_at"),
|
||||
"correction_authorized": bool((lock or {}).get("correction_authorized")),
|
||||
"correction_pr_number": (lock or {}).get("correction_pr_number"),
|
||||
"correction_head_sha": normalize_head_sha(
|
||||
(lock or {}).get("correction_head_sha")
|
||||
),
|
||||
"mark_final_allowed": True,
|
||||
"cleanup_allowed": False,
|
||||
"correction_eligible": False,
|
||||
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
|
||||
"reasons": [],
|
||||
"evidence": {},
|
||||
}
|
||||
|
||||
if lock is None:
|
||||
result["reasons"].append("no review decision lock present")
|
||||
return result
|
||||
|
||||
session_blockers = list(session_blockers or [])
|
||||
if session_blockers:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"reconnect matching prgs-reviewer session or wait for lock TTL; "
|
||||
"do not use gitea_authorize_review_correction as unlock; "
|
||||
"do not delete session-state files"
|
||||
)
|
||||
result["reasons"].extend(session_blockers)
|
||||
return result
|
||||
|
||||
stored = (
|
||||
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||
.strip()
|
||||
)
|
||||
active = (active_profile_identity or "").strip()
|
||||
if active and stored and active != stored:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
f"switch to profile '{stored}' or wait for moot cleanup; "
|
||||
"do not use correction authorization as a generic unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"lock profile '{stored}' does not match active '{active}'"
|
||||
)
|
||||
return result
|
||||
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
ready_pr = lock.get("ready_pr_number")
|
||||
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||
if (
|
||||
target_pr_number is not None
|
||||
and ready_pr == target_pr_number
|
||||
and ready_head
|
||||
and target_head
|
||||
and heads_equal(ready_head, target_head)
|
||||
and lock.get("final_review_decision_ready")
|
||||
):
|
||||
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
|
||||
result["mark_final_allowed"] = True # idempotent re-mark
|
||||
result["next_action"] = (
|
||||
"gitea_submit_pr_review with final_review_decision_ready=true "
|
||||
"for the ready PR/head"
|
||||
)
|
||||
result["reasons"].append(
|
||||
"final decision already marked ready for this PR/head (idempotent)"
|
||||
)
|
||||
return result
|
||||
result["classification"] = CLASS_READY_FOR_TARGET
|
||||
result["next_action"] = "gitea_mark_final_review_decision then submit"
|
||||
result["reasons"].append("no terminal live mutations; mark_final allowed")
|
||||
return result
|
||||
|
||||
last_pr = last.get("pr_number")
|
||||
last_action = last.get("action")
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
result["last_terminal_pr"] = last_pr
|
||||
result["last_terminal_action"] = last_action
|
||||
result["locked_head_sha"] = locked_head
|
||||
result["evidence"] = {
|
||||
"last_review_id": last.get("review_id"),
|
||||
"live_mutations_count": len(lock.get("live_mutations") or []),
|
||||
}
|
||||
|
||||
if correction_applies(
|
||||
lock, pr_number=target_pr_number, expected_head_sha=target_head
|
||||
):
|
||||
result["classification"] = CLASS_CORRECTION_ACTIVE
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
|
||||
"then submit once"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
|
||||
)
|
||||
return result
|
||||
|
||||
# Live state of last terminal PR when provided.
|
||||
live = None
|
||||
if pr_lookup_error:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"retry diagnosis after live PR state is available; "
|
||||
"fail closed under ambiguous evidence"
|
||||
)
|
||||
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
|
||||
return result
|
||||
if pr_live is not None:
|
||||
live = classify_pr_live_state(pr_live)
|
||||
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
|
||||
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
|
||||
if live.get("pr_merged_or_closed"):
|
||||
result["classification"] = CLASS_MOOT_MERGED_CLOSED
|
||||
result["cleanup_allowed"] = True
|
||||
result["mark_final_allowed"] = target_pr_number is not None and (
|
||||
int(last_pr) != int(target_pr_number)
|
||||
if last_pr is not None and target_pr_number is not None
|
||||
else True
|
||||
)
|
||||
result["next_action"] = (
|
||||
"gitea_cleanup_stale_review_decision_lock(apply=true, "
|
||||
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
|
||||
)
|
||||
return result
|
||||
|
||||
if target_pr_number is None:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = "re-run diagnosis with target_pr_number"
|
||||
result["reasons"].append("target_pr_number required for open-PR classification")
|
||||
return result
|
||||
|
||||
if last_pr is not None and int(last_pr) != int(target_pr_number):
|
||||
# Cross-PR isolation: foreign terminal is history, not a block.
|
||||
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
|
||||
result["mark_final_allowed"] = True
|
||||
result["correction_eligible"] = False # must not use correction to "unlock"
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
|
||||
f"— foreign terminal on PR #{last_pr} does not block (#693); "
|
||||
"do not call gitea_authorize_review_correction for cross-PR unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
|
||||
f"target PR #{target_pr_number} is isolated (#693)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR as target.
|
||||
if (
|
||||
locked_head
|
||||
and target_head
|
||||
and not heads_equal(locked_head, target_head)
|
||||
):
|
||||
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision with expected_head_sha="
|
||||
f"{target_head} (#620 same-PR new head)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} on head {locked_head[:12]}…; target head "
|
||||
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR + same head (or missing head comparison) — active terminal.
|
||||
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
|
||||
result["mark_final_allowed"] = False
|
||||
result["correction_eligible"] = True
|
||||
result["next_action"] = (
|
||||
"if the prior verdict was mistaken: gitea_authorize_review_correction "
|
||||
f"with prior_review_id={last.get('review_id')}, "
|
||||
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
|
||||
"operator_authorized=true, then re-mark once; "
|
||||
"if the prior verdict is correct: stop and hand off (do not duplicate)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} already recorded for PR #{target_pr_number} "
|
||||
f"at this head; same-head duplicate blocked (#332/#620)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_precise_mark_final_failure(
|
||||
classification: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
|
||||
cls = classification.get("classification")
|
||||
next_action = classification.get("next_action") or (
|
||||
"stop and create a durable Gitea issue with lock evidence"
|
||||
)
|
||||
reasons = list(classification.get("reasons") or [])
|
||||
reasons.append(f"exact_next_action: {next_action}")
|
||||
handoff = {
|
||||
"required": cls
|
||||
in (
|
||||
CLASS_AMBIGUOUS,
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH,
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
|
||||
)
|
||||
and not classification.get("mark_final_allowed"),
|
||||
"title_hint": (
|
||||
"Review-decision-lock recovery failed during formal review"
|
||||
),
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||
"last_terminal_action": classification.get("last_terminal_action"),
|
||||
"locked_head_sha": classification.get("locked_head_sha"),
|
||||
"target_pr_number": classification.get("target_pr_number"),
|
||||
"target_head_sha": classification.get("target_head_sha"),
|
||||
"evidence": classification.get("evidence") or {},
|
||||
"instruction": (
|
||||
"If recovery cannot complete with the exact_next_action above, "
|
||||
"create or update a durable Gitea issue with this payload and stop; "
|
||||
"do not delete session-state files; do not misuse correction as unlock."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"reasons": reasons,
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"durable_issue_handoff": handoff,
|
||||
}
|
||||
|
||||
|
||||
def build_correction_audit_record(
|
||||
*,
|
||||
prior_review_id: int | None,
|
||||
prior_review_state: str | None,
|
||||
target_pr_number: int | None,
|
||||
target_head_sha: str | None,
|
||||
reason: str | None,
|
||||
actor_username: str | None,
|
||||
profile_name: str | None,
|
||||
authorized: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured durable audit for review-correction authorization (#693)."""
|
||||
return {
|
||||
"event": "review_correction_authorization",
|
||||
"issue_ref": "#693",
|
||||
"authorized": bool(authorized),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"actor_username": actor_username,
|
||||
"profile_name": profile_name,
|
||||
"prior_review_id": prior_review_id,
|
||||
"prior_review_state": prior_review_state,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": normalize_head_sha(target_head_sha),
|
||||
"reason": reason,
|
||||
"scope": "same_pr_head_only",
|
||||
}
|
||||
|
||||
|
||||
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
|
||||
"""Markdown body for a thread-visible correction authorization audit."""
|
||||
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
|
||||
lines = [
|
||||
"## Review correction authorization audit (#693)",
|
||||
"",
|
||||
f"Status: **{status}**",
|
||||
"",
|
||||
f"- actor: `{audit.get('actor_username')}`",
|
||||
f"- profile: `{audit.get('profile_name')}`",
|
||||
f"- timestamp: `{audit.get('timestamp')}`",
|
||||
f"- prior_review_id: `{audit.get('prior_review_id')}`",
|
||||
f"- prior_review_state: `{audit.get('prior_review_state')}`",
|
||||
f"- target_pr: `#{audit.get('target_pr_number')}`",
|
||||
f"- target_head_sha: `{audit.get('target_head_sha')}`",
|
||||
f"- reason: {audit.get('reason')}",
|
||||
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
|
||||
"",
|
||||
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
|
||||
"uses isolation (#693) or moot cleanup (#594), never correction.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_open_pr_decision_lock_recovery(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int,
|
||||
target_head_sha: str | None,
|
||||
last_terminal_pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
controller_recovery_authorized: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
|
||||
|
||||
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
|
||||
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
|
||||
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
|
||||
"""
|
||||
classification = classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=target_head_sha,
|
||||
pr_live=last_terminal_pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=active_profile_identity,
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
cls = classification["classification"]
|
||||
recovery_allowed = False
|
||||
recovery_mode = None
|
||||
if cls == CLASS_FOREIGN_PR_TERMINAL:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "cross_pr_isolation"
|
||||
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "same_pr_new_head"
|
||||
elif cls == CLASS_MOOT_MERGED_CLOSED:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "moot_cleanup"
|
||||
elif cls == CLASS_READY_FOR_TARGET:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "none_required"
|
||||
elif cls == CLASS_CORRECTION_ACTIVE:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "scoped_correction"
|
||||
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "correction_only_if_mistake"
|
||||
else:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "fail_closed"
|
||||
|
||||
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
|
||||
# Cleanup still needs apply + capability; assess reports path.
|
||||
pass
|
||||
|
||||
return {
|
||||
**classification,
|
||||
"recovery_allowed": recovery_allowed,
|
||||
"recovery_mode": recovery_mode,
|
||||
"controller_recovery_authorized": bool(controller_recovery_authorized),
|
||||
"manual_file_delete_forbidden": True,
|
||||
"correction_as_generic_unlock_forbidden": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+187
-1
@@ -36,6 +36,20 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #781: editing an issue title/body is issue authoring, the same authority
|
||||
# every other non-create/non-close issue mutation gates on. Deliberately not
|
||||
# a new operation name: introducing one would silently strip the capability
|
||||
# from every already-configured author profile.
|
||||
"edit_issue": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #780: retire status:pr-open after a terminal PR transition. Same label
|
||||
# authority as set_issue_labels — it is a strictly narrower operation.
|
||||
"cleanup_terminal_pr_labels": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
"create_label": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
@@ -60,18 +74,57 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.close",
|
||||
"role": "author",
|
||||
},
|
||||
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
|
||||
"edit_pr": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_edit_pr": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"address_pr_change_requests": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||
"assess_pr_sync_status": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_assess_pr_sync_status": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"update_pr_branch_by_merge": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_update_pr_branch_by_merge": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
"review_pr": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"submit_pr_review": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"merge_pr": {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
"acquire_reviewer_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_acquire_reviewer_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #695 AC8: controller quarantine of contaminated formal reviews.
|
||||
# Apply path posts an append-only forensic audit comment (pr.comment).
|
||||
"quarantine_contaminated_review": {
|
||||
@@ -86,6 +139,29 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"gitea_adopt_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"acquire_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"gitea_acquire_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
# #742: owner-session terminal release/abandon of a merger-held lease when
|
||||
# the merge does not occur. Apply path posts an append-only terminal lease
|
||||
# marker (gitea.pr.comment); merger-only, never a reviewer path.
|
||||
"release_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"gitea_release_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||
"cleanup_obsolete_reviewer_comment_lease": {
|
||||
@@ -96,6 +172,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned. The
|
||||
# apply path posts an append-only terminal `phase: released` lease marker
|
||||
# (gitea.pr.comment), so holding the comment permission alone must not
|
||||
# authorize it — author, reviewer and merger fail closed on the role gate
|
||||
# even though their profiles carry gitea.pr.comment. The read-only
|
||||
# `apply=false` assessment deliberately stays reachable under gitea.read
|
||||
# inside the tool (the same convention as cleanup_stale_review_decision_lock
|
||||
# below), so any namespace can diagnose a stuck lease; only apply requires
|
||||
# this task plus the reconciler role.
|
||||
"cleanup_post_merge_moot_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"gitea_cleanup_post_merge_moot_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"blind_pr_queue_review": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
@@ -127,6 +220,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #693: read-only classification of durable decision locks (incl. open PRs).
|
||||
"diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||
"issue_irrecoverable_provenance_authorization": {
|
||||
@@ -153,9 +263,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
|
||||
# only to the reconciler profile, so the resolver must classify this task as
|
||||
# reconciler (previously "author", which no delete-capable profile held).
|
||||
# Raw gitea_delete_branch still redirects reconciler to the guarded
|
||||
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
|
||||
# stay denied by both the permission gate and this role gate.
|
||||
"delete_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
"role": "author",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"cleanup_merged_pr_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
@@ -330,13 +446,83 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# A reviewer lease is the first mutation in the canonical ``review_pr``
|
||||
# workflow, so the already-resolved review capability is valid for that one
|
||||
# narrower transition. Keep this directed and explicit: lease acquisition
|
||||
# does not authorize a review verdict, and reviewer proof never authorizes a
|
||||
# merger lease (#763).
|
||||
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
||||
("review_pr", "acquire_reviewer_pr_lease"),
|
||||
})
|
||||
|
||||
|
||||
def _canonical_preflight_task(task: str | None) -> str:
|
||||
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
|
||||
value = (task or "").strip()
|
||||
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
|
||||
return value[6:]
|
||||
return value
|
||||
|
||||
|
||||
def preflight_task_matches(
|
||||
resolved_task: str | None,
|
||||
mutation_task: str | None,
|
||||
) -> bool:
|
||||
"""Return whether capability proof authorizes this mutation transition."""
|
||||
resolved = _canonical_preflight_task(resolved_task)
|
||||
mutation = _canonical_preflight_task(mutation_task)
|
||||
if not resolved or not mutation:
|
||||
return False
|
||||
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
|
||||
|
||||
|
||||
# Tasks for which permission alone is insufficient: the active/configured
|
||||
# profile's declared role must also match the task role. This is the complete
|
||||
# resolver set from master at the #723 reconstruction point, shared with
|
||||
# runtime reporting so those two authorities cannot drift again.
|
||||
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||
{
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"merge_pr",
|
||||
"acquire_merger_pr_lease",
|
||||
"gitea_acquire_merger_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"gitea_adopt_merger_pr_lease",
|
||||
"release_merger_pr_lease",
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"update_pr_branch_by_merge",
|
||||
"gitea_update_pr_branch_by_merge",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
)
|
||||
|
||||
# Issue-mutating MCP tools and their resolver task keys.
|
||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_create_issue": "create_issue",
|
||||
"gitea_close_issue": "close_issue",
|
||||
"gitea_edit_issue": "edit_issue",
|
||||
"gitea_create_issue_comment": "comment_issue",
|
||||
"gitea_mark_issue": "mark_issue",
|
||||
"gitea_set_issue_labels": "set_issue_labels",
|
||||
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||
"gitea_create_label": "create_label",
|
||||
"gitea_commit_files": "commit_files",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Authoritative terminal-transition cleanup for ``status:pr-open`` (#780).
|
||||
|
||||
``status:pr-open`` is applied by ``gitea_create_pr`` while a linked pull
|
||||
request is open. Nothing removed it again: the workflow's terminal paths
|
||||
(merge, close-without-merge, supersession, already-landed reconciliation,
|
||||
controller closure) each ended without touching the label, so a repository
|
||||
audit found 40 closed issues still carrying it.
|
||||
|
||||
This module is the single source of truth for that cleanup. Every sanctioned
|
||||
terminal path plans its label mutation here rather than implementing its own
|
||||
rule, so the paths cannot drift apart:
|
||||
|
||||
- :func:`plan_pr_open_cleanup` decides the exact resulting label set. It only
|
||||
ever removes ``status:pr-open``; every other label is preserved verbatim,
|
||||
including the case where the result is an empty label set.
|
||||
- :func:`verify_pr_open_cleanup` is the read-after-write check. It proves the
|
||||
label is gone *and* that no unrelated label was dropped or added.
|
||||
- :func:`detect_residual_pr_open` is the terminal validation: given issues, it
|
||||
reports any that still carry the label, so a controller closure or audit
|
||||
fails loudly instead of leaving the leak behind.
|
||||
|
||||
The rule is idempotent by construction: an issue without the label plans no
|
||||
mutation, so retries and recovery re-runs are harmless.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: The single label this module is responsible for retiring.
|
||||
PR_OPEN_LABEL = "status:pr-open"
|
||||
|
||||
# Canonical terminal reasons — the sanctioned ways an issue can end up
|
||||
# associated with a pull request that is no longer open.
|
||||
MERGED = "merged"
|
||||
CLOSED_WITHOUT_MERGE = "closed_without_merge"
|
||||
SUPERSEDED = "superseded"
|
||||
ALREADY_LANDED = "already_landed"
|
||||
CONTROLLER_CLOSURE = "controller_closure"
|
||||
ABANDONED = "abandoned"
|
||||
RETRY_RECOVERY = "retry_recovery"
|
||||
|
||||
TERMINAL_REASONS: tuple[str, ...] = (
|
||||
MERGED,
|
||||
CLOSED_WITHOUT_MERGE,
|
||||
SUPERSEDED,
|
||||
ALREADY_LANDED,
|
||||
CONTROLLER_CLOSURE,
|
||||
ABANDONED,
|
||||
RETRY_RECOVERY,
|
||||
)
|
||||
|
||||
_REASON_ALIASES: dict[str, str] = {
|
||||
"merge": MERGED,
|
||||
"merged": MERGED,
|
||||
"pr_merged": MERGED,
|
||||
"closed": CLOSED_WITHOUT_MERGE,
|
||||
"close": CLOSED_WITHOUT_MERGE,
|
||||
"closed_without_merge": CLOSED_WITHOUT_MERGE,
|
||||
"pr_closed": CLOSED_WITHOUT_MERGE,
|
||||
"supersede": SUPERSEDED,
|
||||
"superseded": SUPERSEDED,
|
||||
"supersession": SUPERSEDED,
|
||||
"already_landed": ALREADY_LANDED,
|
||||
"reconcile_already_landed": ALREADY_LANDED,
|
||||
"controller_closure": CONTROLLER_CLOSURE,
|
||||
"close_issue": CONTROLLER_CLOSURE,
|
||||
"abandon": ABANDONED,
|
||||
"abandoned": ABANDONED,
|
||||
"retry": RETRY_RECOVERY,
|
||||
"recovery": RETRY_RECOVERY,
|
||||
"retry_recovery": RETRY_RECOVERY,
|
||||
}
|
||||
|
||||
#: Human-readable phrasing used in audit comments and diagnostics.
|
||||
REASON_DESCRIPTIONS: dict[str, str] = {
|
||||
MERGED: "the linked PR was merged",
|
||||
CLOSED_WITHOUT_MERGE: "the linked PR was closed without merging",
|
||||
SUPERSEDED: "the linked PR was superseded by another merged PR",
|
||||
ALREADY_LANDED: "the linked PR's change was already on the target branch",
|
||||
CONTROLLER_CLOSURE: "the issue reached controller closure",
|
||||
ABANDONED: "the linked PR was abandoned",
|
||||
RETRY_RECOVERY: "a partial terminal transition is being recovered",
|
||||
}
|
||||
|
||||
|
||||
def canonical_terminal_reason(reason: str | None) -> str:
|
||||
"""Normalize a terminal reason, failing closed on anything unrecognized."""
|
||||
name = (reason or "").strip()
|
||||
if name in TERMINAL_REASONS:
|
||||
return name
|
||||
normalized = name.lower().replace("-", "_").replace(" ", "_")
|
||||
try:
|
||||
return _REASON_ALIASES[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown terminal PR reason '{reason}' (expected one of: "
|
||||
+ ", ".join(TERMINAL_REASONS)
|
||||
+ ")"
|
||||
) from exc
|
||||
|
||||
|
||||
def plan_pr_open_cleanup(
|
||||
current_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the label set an issue must carry after a terminal PR transition.
|
||||
|
||||
The plan removes ``status:pr-open`` and nothing else. When the label is
|
||||
absent the plan is an explicit no-op (``cleanup_required`` False), which is
|
||||
what makes repeated cleanup calls harmless. When it was the only label the
|
||||
resulting set is legitimately empty.
|
||||
"""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
before = issue_workflow_labels.label_names(current_labels)
|
||||
after = [name for name in before if name != PR_OPEN_LABEL]
|
||||
present = len(after) != len(before)
|
||||
return {
|
||||
"terminal_reason": reason,
|
||||
"terminal_reason_description": REASON_DESCRIPTIONS[reason],
|
||||
"label": PR_OPEN_LABEL,
|
||||
"label_present": present,
|
||||
"cleanup_required": present,
|
||||
"idempotent_noop": not present,
|
||||
"labels_before": before,
|
||||
"labels_after": after,
|
||||
"removed": [PR_OPEN_LABEL] if present else [],
|
||||
"preserved": list(after),
|
||||
"empty_label_set": not after,
|
||||
}
|
||||
|
||||
|
||||
def verify_pr_open_cleanup(
|
||||
observed_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write check for a planned cleanup.
|
||||
|
||||
Verifies the label is gone and that the observed set matches the plan
|
||||
exactly, so an unrelated label silently dropped (or re-added) by the API is
|
||||
reported rather than accepted.
|
||||
"""
|
||||
observed = issue_workflow_labels.label_names(observed_labels)
|
||||
expected = list(plan.get("labels_after") or [])
|
||||
observed_set = set(observed)
|
||||
expected_set = set(expected)
|
||||
residual = PR_OPEN_LABEL in observed_set
|
||||
unexpected_removals = sorted(expected_set - observed_set)
|
||||
unexpected_additions = sorted(observed_set - expected_set - {PR_OPEN_LABEL})
|
||||
|
||||
reasons: list[str] = []
|
||||
if residual:
|
||||
reasons.append(
|
||||
f"'{PR_OPEN_LABEL}' is still present after terminal cleanup"
|
||||
)
|
||||
if unexpected_removals:
|
||||
reasons.append(
|
||||
"unrelated labels were dropped by the cleanup: "
|
||||
+ ", ".join(unexpected_removals)
|
||||
)
|
||||
if unexpected_additions:
|
||||
reasons.append(
|
||||
"unexpected labels appeared during the cleanup: "
|
||||
+ ", ".join(unexpected_additions)
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"residual": residual,
|
||||
"observed_labels": observed,
|
||||
"expected_labels": expected,
|
||||
"unexpected_removals": unexpected_removals,
|
||||
"unexpected_additions": unexpected_additions,
|
||||
"empty_label_set": not observed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
"Re-run the terminal cleanup for this issue with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' and confirm the read-back "
|
||||
f"no longer reports '{PR_OPEN_LABEL}'."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def summarize_cleanup_results(
|
||||
results: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate per-issue cleanup outcomes into one reportable record."""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
entries = [dict(entry) for entry in results]
|
||||
removed = [e.get("issue_number") for e in entries if e.get("status") == "removed"]
|
||||
absent = [
|
||||
e.get("issue_number") for e in entries if e.get("status") == "not present"
|
||||
]
|
||||
failed = [
|
||||
e.get("issue_number")
|
||||
for e in entries
|
||||
if e.get("status") not in ("removed", "not present") or not e.get("verified")
|
||||
]
|
||||
reasons: list[str] = []
|
||||
for entry in entries:
|
||||
for text in entry.get("reasons") or []:
|
||||
reasons.append(f"issue #{entry.get('issue_number')}: {text}")
|
||||
clean = not failed
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"terminal_reason": reason,
|
||||
"clean": clean,
|
||||
"checked": [e.get("issue_number") for e in entries],
|
||||
"removed": removed,
|
||||
"already_absent": absent,
|
||||
"failed": failed,
|
||||
"results": entries,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Terminal label cleanup did not complete for "
|
||||
+ ", ".join(f"#{num}" for num in failed)
|
||||
+ ". Re-run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for those issues."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def detect_residual_pr_open(
|
||||
issues: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
open_pr_issue_numbers: Iterable[int] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Terminal validation: report issues still carrying ``status:pr-open``.
|
||||
|
||||
An issue with a genuinely open pull request is allowed to keep the label,
|
||||
so *open_pr_issue_numbers* is excluded from the residual set rather than
|
||||
being reported as a leak.
|
||||
"""
|
||||
legitimate: set[int] = set()
|
||||
for num in open_pr_issue_numbers or ():
|
||||
try:
|
||||
legitimate.add(int(num))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
checked = 0
|
||||
residual: list[dict[str, Any]] = []
|
||||
exempt: list[int] = []
|
||||
|
||||
for issue in issues or []:
|
||||
checked += 1
|
||||
names = issue_workflow_labels.label_names(issue)
|
||||
if PR_OPEN_LABEL not in names:
|
||||
continue
|
||||
try:
|
||||
number = int(issue.get("number"))
|
||||
except (TypeError, ValueError):
|
||||
number = None
|
||||
if number is not None and number in legitimate:
|
||||
exempt.append(number)
|
||||
continue
|
||||
residual.append(
|
||||
{
|
||||
"number": number,
|
||||
"state": issue.get("state"),
|
||||
"labels": names,
|
||||
}
|
||||
)
|
||||
|
||||
clean = not residual
|
||||
reasons = [
|
||||
(
|
||||
f"issue #{entry['number']} ({entry.get('state') or 'unknown state'}) "
|
||||
f"still carries '{PR_OPEN_LABEL}' with no open PR"
|
||||
)
|
||||
for entry in residual
|
||||
]
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"clean": clean,
|
||||
"checked_count": checked,
|
||||
"residual_count": len(residual),
|
||||
"residual_issues": residual,
|
||||
"exempt_open_pr_issues": sorted(exempt),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for issues "
|
||||
+ ", ".join(f"#{entry['number']}" for entry in residual)
|
||||
+ " before declaring the terminal transition complete."
|
||||
)
|
||||
),
|
||||
}
|
||||
+92
-17
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
|
||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||
so durable load/save never touches host state even after env clears.
|
||||
"""
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
# Each pytest item is an independent logical MCP session. Reset both before
|
||||
# and after the item; the post-yield reset is in a finally block so an
|
||||
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||
# next item. Production code has no automatic per-call reset path.
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
for env_key in [
|
||||
"GITEA_SESSION_PROFILE_LOCK",
|
||||
"GITEA_ACTIVE_WORKTREE",
|
||||
@@ -80,11 +88,31 @@ def _reset_mutation_authority(monkeypatch):
|
||||
try:
|
||||
import mcp_server
|
||||
except Exception:
|
||||
_state_tmp.cleanup()
|
||||
yield
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
return
|
||||
import gitea_config
|
||||
|
||||
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||
# object with its own identity caches; leaving it dirty leaks login across
|
||||
# tests that import the implementation module directly.
|
||||
try:
|
||||
import gitea_mcp_server as _gitea_impl
|
||||
|
||||
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(mcp_server, "_ACTOR_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)
|
||||
@@ -113,19 +141,66 @@ def _reset_mutation_authority(monkeypatch):
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
|
||||
|
||||
# #714: deterministic workspace remotes only (no host git dependency).
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_live_remote_master_head():
|
||||
"""#610 / PR #788 F1/F2: keep live-remote parity reads offline in tests.
|
||||
|
||||
``read_remote_master_head`` would otherwise ``git ls-remote`` whenever
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` is unset. Feature worktrees under
|
||||
``branches/`` always differ from live master, so legacy suites that assert
|
||||
runtime-context ``safe_next_action`` flip to live_stale. Module-level
|
||||
hermetic mode survives ``patch.dict(os.environ, …, clear=True)``.
|
||||
Tests that exercise the real probe path call
|
||||
``master_parity_gate.set_hermetic_test_mode(False)`` and/or set
|
||||
``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
try:
|
||||
import master_parity_gate as _mpg
|
||||
|
||||
_mpg.set_hermetic_test_mode(True)
|
||||
except Exception:
|
||||
_mpg = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _mpg is not None:
|
||||
try:
|
||||
_mpg.set_hermetic_test_mode(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deterministic_workspace_remotes():
|
||||
try:
|
||||
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||
install_deterministic_remote_urls()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
_state_tmp.cleanup()
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Centralized config-backed mutation fixture for #714.
|
||||
|
||||
Mutation tests must opt into this helper explicitly. It never grants
|
||||
mutation authority via environment variables alone.
|
||||
|
||||
Design rules:
|
||||
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||
- profile-scoped operations (not every mutation op for every test)
|
||||
- deterministic workspace remotes (no host Git dependency)
|
||||
- one canonical repository per profile by default
|
||||
- isolated state + cleanup in ``finally``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from typing import Iterable, Sequence
|
||||
from unittest.mock import patch
|
||||
|
||||
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||
MDCPS_SLUG = "913443/eAgenda"
|
||||
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||
|
||||
|
||||
def _author_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.close",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
|
||||
def _author_forbidden() -> list[str]:
|
||||
return [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.review",
|
||||
]
|
||||
|
||||
|
||||
def _reviewer_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
|
||||
|
||||
def _merger_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
]
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
name: str,
|
||||
context: str,
|
||||
role: str,
|
||||
base_url: str,
|
||||
auth_env: str,
|
||||
allowed_operations: Sequence[str],
|
||||
forbidden_operations: Sequence[str],
|
||||
allowed_repositories: Sequence[str],
|
||||
username: str | None = None,
|
||||
) -> dict:
|
||||
body = {
|
||||
"enabled": True,
|
||||
"context": context,
|
||||
"role": role,
|
||||
"base_url": base_url,
|
||||
"auth": {"type": "env", "name": auth_env},
|
||||
"allowed_operations": list(allowed_operations),
|
||||
"forbidden_operations": list(forbidden_operations),
|
||||
"allowed_repositories": list(allowed_repositories),
|
||||
"execution_profile": name,
|
||||
}
|
||||
if username:
|
||||
body["username"] = username
|
||||
return body
|
||||
|
||||
|
||||
def build_dual_remote_config(
|
||||
*,
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
) -> dict:
|
||||
"""Build a minimal dual-host v2 config.
|
||||
|
||||
Each profile authorizes only its host-canonical repository by default.
|
||||
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||
REMOTES to that synthetic unit-test identity.
|
||||
"""
|
||||
ops = list(allowed_operations or _author_ops())
|
||||
forb = _author_forbidden()
|
||||
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||
if include_example_repo:
|
||||
for repos in (prgs_repos, mdcps_repos):
|
||||
if EXAMPLE_SLUG not in repos:
|
||||
repos.append(EXAMPLE_SLUG)
|
||||
|
||||
profiles = {
|
||||
"test-author-prgs": _profile(
|
||||
name="test-author-prgs",
|
||||
context="prgs",
|
||||
role="author",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-author-dadeschools": _profile(
|
||||
name="test-author-dadeschools",
|
||||
context="mdcps",
|
||||
role="author",
|
||||
base_url="https://gitea.dadeschools.net",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=mdcps_repos,
|
||||
),
|
||||
"test-reviewer-prgs": _profile(
|
||||
name="test-reviewer-prgs",
|
||||
context="prgs",
|
||||
role="reviewer",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_reviewer_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.merge",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-merger-prgs": _profile(
|
||||
name="test-merger-prgs",
|
||||
context="prgs",
|
||||
role="merger",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_merger_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
}
|
||||
if extra_profiles:
|
||||
profiles.update(deepcopy(extra_profiles))
|
||||
|
||||
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||
for alias, base in (
|
||||
("gitea-author", "test-author-prgs"),
|
||||
("author-test", "test-author-dadeschools"),
|
||||
("author", "test-author-dadeschools"),
|
||||
("full-author", "test-author-dadeschools"),
|
||||
("test-author", "test-author-dadeschools"),
|
||||
("prgs-author", "test-author-prgs"),
|
||||
("gitea-reviewer", "test-reviewer-prgs"),
|
||||
("prgs-reviewer", "test-reviewer-prgs"),
|
||||
("gitea-merger", "test-merger-prgs"),
|
||||
("prgs-merger", "test-merger-prgs"),
|
||||
):
|
||||
if alias not in profiles and base in profiles:
|
||||
clone = deepcopy(profiles[base])
|
||||
clone["execution_profile"] = alias
|
||||
profiles[alias] = clone
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {
|
||||
"enabled": True,
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
|
||||
def build_v2_config(**kwargs):
|
||||
"""Back-compat alias."""
|
||||
return build_dual_remote_config(
|
||||
allowed_operations=kwargs.get("allowed_operations"),
|
||||
extra_profiles=kwargs.get("extra_profiles"),
|
||||
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||
)
|
||||
|
||||
|
||||
def inject_allowed_repositories(
|
||||
config: dict,
|
||||
repos: Sequence[str],
|
||||
*,
|
||||
profiles: Sequence[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||
out = deepcopy(config)
|
||||
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||
for name, prof in (out.get("profiles") or {}).items():
|
||||
if name in targets:
|
||||
prof["allowed_repositories"] = list(repos)
|
||||
return out
|
||||
|
||||
|
||||
def ensure_all_profiles_have_scope(
|
||||
config: dict,
|
||||
default_repos: Sequence[str],
|
||||
) -> dict:
|
||||
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||
out = deepcopy(config)
|
||||
for _name, prof in (out.get("profiles") or {}).items():
|
||||
raw = prof.get("allowed_repositories")
|
||||
if not isinstance(raw, (list, tuple)) or not raw:
|
||||
prof["allowed_repositories"] = list(default_repos)
|
||||
return out
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mutation_profile_env(
|
||||
*,
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories: Sequence[str] | None = None,
|
||||
workspace_urls: dict | None = None,
|
||||
clear_env: bool = False,
|
||||
extra_env: dict | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
config: dict | None = None,
|
||||
):
|
||||
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||
import gitea_config
|
||||
import gitea_mcp_server as srv
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
if config is None:
|
||||
prgs_repos = None
|
||||
mdcps_repos = None
|
||||
if allowed_repositories is not None:
|
||||
# Caller-specified single allowlist applied to both host profiles.
|
||||
prgs_repos = list(allowed_repositories)
|
||||
mdcps_repos = list(allowed_repositories)
|
||||
cfg = build_dual_remote_config(
|
||||
allowed_operations=allowed_operations,
|
||||
allowed_repositories_prgs=prgs_repos,
|
||||
allowed_repositories_mdcps=mdcps_repos,
|
||||
extra_profiles=extra_profiles,
|
||||
include_example_repo=include_example_repo,
|
||||
)
|
||||
else:
|
||||
cfg = deepcopy(config)
|
||||
if allowed_repositories is not None:
|
||||
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||
|
||||
urls = (
|
||||
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||
if workspace_urls is None
|
||||
else dict(workspace_urls)
|
||||
)
|
||||
|
||||
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||
try:
|
||||
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cfg, fh)
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": cfg_path,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
def _remote_url(name: str):
|
||||
if name in urls:
|
||||
return urls[name]
|
||||
# Prefer live REMOTES (tests often patch Example-Org).
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
gitea_config._active_profile_override = None
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {
|
||||
"env": env,
|
||||
"config_path": cfg_path,
|
||||
"profile_name": profile_name,
|
||||
"urls": urls,
|
||||
"config": cfg,
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=clear_env):
|
||||
os.environ.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||
mcp_srv = None
|
||||
try:
|
||||
import mcp_server as mcp_srv # type: ignore
|
||||
except Exception:
|
||||
mcp_srv = None
|
||||
if mcp_srv is not None:
|
||||
with patch.object(
|
||||
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||
):
|
||||
yield payload
|
||||
else:
|
||||
yield payload
|
||||
finally:
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||
_SHARED_CFG_PATH = None
|
||||
_SHARED_CFG_DIR = None
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||
|
||||
|
||||
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||
"""Write (once) a dual-remote mutation config and return its path."""
|
||||
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
import atexit
|
||||
import shutil
|
||||
|
||||
if include_example_repo:
|
||||
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
):
|
||||
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||
return path
|
||||
|
||||
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||
return _SHARED_CFG_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(), fh)
|
||||
return _SHARED_CFG_PATH
|
||||
|
||||
|
||||
def shared_mutation_env(
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
*,
|
||||
include_example_repo: bool = False,
|
||||
**extra,
|
||||
) -> dict:
|
||||
"""Env dict for mutation tests: config-backed + optional extras.
|
||||
|
||||
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||
"""
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||
include_example_repo=include_example_repo
|
||||
),
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
env.update(extra)
|
||||
# Callers must not be able to drop the pytest marker by accident.
|
||||
env.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def install_deterministic_remote_urls() -> None:
|
||||
"""Patch server modules so workspace remotes are deterministic.
|
||||
|
||||
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||
workspace alignment). Map the historical prgs→Timesheet default to
|
||||
Gitea-Tools so session binding matches the control repository under test.
|
||||
"""
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
def _url(name: str):
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
if name == "prgs":
|
||||
return PRGS_URL
|
||||
if name == "dadeschools":
|
||||
return MDCPS_URL
|
||||
return None
|
||||
|
||||
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
try:
|
||||
import mcp_server as mcp_srv
|
||||
|
||||
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,3 +1,7 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||
import json
|
||||
import os
|
||||
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
|
||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
)
|
||||
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Dependency parsing/resolution and allocator completeness tests (#758).
|
||||
|
||||
Covers the two defects behind #758:
|
||||
|
||||
* Defect 1 — candidate truncation before ranking, which let a result-size
|
||||
parameter change the winner.
|
||||
* Defect 2 — dependency state inferred from body substrings, which emitted
|
||||
canonical ``Depends:`` blocked issues as eligible.
|
||||
|
||||
No production behavior is special-cased for any issue number (#758 AC14), so
|
||||
these tests use synthetic issue numbers throughout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import allocator_dependencies
|
||||
from allocator_service import (
|
||||
OUTCOME_PREVIEW,
|
||||
SELECTION_POLICY,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_skip,
|
||||
sort_candidates,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
# The canonical linkage line this repository writes into issue bodies.
|
||||
CANONICAL_BODY = (
|
||||
"## Dependencies and linkage\n\n"
|
||||
"* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
|
||||
)
|
||||
|
||||
|
||||
class ParseDependencyRefsTest(unittest.TestCase):
|
||||
def test_parses_canonical_depends_field(self) -> None:
|
||||
self.assertEqual(
|
||||
allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
|
||||
(901, 902),
|
||||
)
|
||||
|
||||
def test_stops_at_sibling_field_and_ignores_related(self) -> None:
|
||||
""""Related:" refs must never be treated as dependencies."""
|
||||
refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
|
||||
self.assertNotIn(903, refs)
|
||||
self.assertNotIn(904, refs)
|
||||
self.assertNotIn(900, refs) # Parent is not a dependency
|
||||
|
||||
def test_single_reference(self) -> None:
|
||||
body = "* Parent: #10 · Depends: #11 · Related: #12"
|
||||
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))
|
||||
|
||||
def test_depends_on_spelling_and_and_separator(self) -> None:
|
||||
body = "Depends on #21 and #22\n"
|
||||
self.assertEqual(
|
||||
allocator_dependencies.parse_dependency_refs(body), (21, 22)
|
||||
)
|
||||
|
||||
def test_newline_terminates_declaration(self) -> None:
|
||||
body = "Depends: #31, #32\nRelated: #33\n"
|
||||
self.assertEqual(
|
||||
allocator_dependencies.parse_dependency_refs(body), (31, 32)
|
||||
)
|
||||
|
||||
def test_legacy_blocked_on_marker_still_recognized(self) -> None:
|
||||
body = "This work is blocked on #41 until that lands.\n"
|
||||
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))
|
||||
|
||||
def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
|
||||
""""Dependencies and linkage" must not parse as "Depends"."""
|
||||
body = "## Dependencies and linkage\n\n* Related: #51\n"
|
||||
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||
|
||||
def test_deduplicates_and_preserves_order(self) -> None:
|
||||
body = "Depends: #61, #62, #61\n"
|
||||
self.assertEqual(
|
||||
allocator_dependencies.parse_dependency_refs(body), (61, 62)
|
||||
)
|
||||
|
||||
def test_malformed_and_empty_inputs(self) -> None:
|
||||
for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
|
||||
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||
|
||||
|
||||
class ResolveDependencyStateTest(unittest.TestCase):
|
||||
def test_open_dependency_is_unmet(self) -> None:
|
||||
result = allocator_dependencies.resolve_dependency_state(
|
||||
(901, 902), lambda n: "open", subject="issue#644"
|
||||
)
|
||||
self.assertTrue(result["dependency_unmet"])
|
||||
self.assertEqual(result["unmet"], (901, 902))
|
||||
self.assertIn("#901", result["reason"])
|
||||
|
||||
def test_closed_dependencies_are_met(self) -> None:
|
||||
result = allocator_dependencies.resolve_dependency_state(
|
||||
(901, 902), lambda n: "closed"
|
||||
)
|
||||
self.assertFalse(result["dependency_unmet"])
|
||||
self.assertEqual(result["met"], (901, 902))
|
||||
self.assertIsNone(result["reason"])
|
||||
|
||||
def test_mixed_open_and_closed_is_unmet(self) -> None:
|
||||
states = {901: "closed", 902: "open"}
|
||||
result = allocator_dependencies.resolve_dependency_state(
|
||||
(901, 902), states.get
|
||||
)
|
||||
self.assertTrue(result["dependency_unmet"])
|
||||
self.assertEqual(result["unmet"], (902,))
|
||||
self.assertEqual(result["met"], (901,))
|
||||
|
||||
def test_unavailable_evidence_fails_closed(self) -> None:
|
||||
"""AC7: unknown state must block, never pass."""
|
||||
result = allocator_dependencies.resolve_dependency_state(
|
||||
(901,), lambda n: None
|
||||
)
|
||||
self.assertTrue(result["dependency_unmet"])
|
||||
self.assertEqual(result["unavailable"], (901,))
|
||||
self.assertIn("fail closed", result["reason"])
|
||||
|
||||
def test_raising_lookup_fails_closed(self) -> None:
|
||||
def boom(_n: int) -> str:
|
||||
raise RuntimeError("lookup exploded")
|
||||
|
||||
result = allocator_dependencies.resolve_dependency_state((901,), boom)
|
||||
self.assertTrue(result["dependency_unmet"])
|
||||
self.assertEqual(result["unavailable"], (901,))
|
||||
|
||||
def test_no_refs_is_eligible(self) -> None:
|
||||
result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
|
||||
self.assertFalse(result["dependency_unmet"])
|
||||
self.assertIsNone(result["reason"])
|
||||
|
||||
|
||||
class DependencyBlockedCandidateTest(unittest.TestCase):
|
||||
"""A dependency-blocked candidate must be skipped, not selected."""
|
||||
|
||||
def test_classify_skip_rejects_unmet_dependency(self) -> None:
|
||||
candidate = WorkCandidate(
|
||||
kind="issue",
|
||||
number=644,
|
||||
labels=("status:ready",),
|
||||
priority=20,
|
||||
dependency_unmet=True,
|
||||
dependency_reason="issue#644 depends on unresolved issue(s) #633",
|
||||
)
|
||||
reason = classify_skip(candidate, role="author", terminal_pr=None)
|
||||
self.assertIsNotNone(reason)
|
||||
self.assertIn("#633", reason)
|
||||
|
||||
|
||||
class SelectionInvarianceTest(unittest.TestCase):
|
||||
"""AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _ready_issue(number: int, **kw) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
labels=("status:ready",),
|
||||
priority=20,
|
||||
title=f"issue {number}",
|
||||
**kw,
|
||||
)
|
||||
|
||||
def _preview(self, candidates):
|
||||
return allocate_next_work(
|
||||
self.db,
|
||||
session_id="s-758",
|
||||
role="author",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=candidates,
|
||||
apply=False,
|
||||
)
|
||||
|
||||
def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
|
||||
"""Winner is the oldest eligible issue across a >50 inventory."""
|
||||
candidates = [self._ready_issue(n) for n in range(600, 700)] # 100 items
|
||||
result = self._preview(candidates)
|
||||
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(result["selected"]["number"], 600)
|
||||
|
||||
def test_selection_is_invariant_to_candidate_ordering(self) -> None:
|
||||
"""Ranking must not depend on the order the inventory arrived in."""
|
||||
forward = [self._ready_issue(n) for n in range(600, 700)]
|
||||
reverse = list(reversed(forward))
|
||||
self.assertEqual(
|
||||
self._preview(forward)["selected"]["number"],
|
||||
self._preview(reverse)["selected"]["number"],
|
||||
)
|
||||
|
||||
def test_truncating_inventory_changes_winner(self) -> None:
|
||||
"""Regression guard: this is exactly what pre-ranking slicing did.
|
||||
|
||||
A 50-item slice of a 100-item inventory yields a different winner, so
|
||||
any future reintroduction of pre-ranking truncation is detectable.
|
||||
"""
|
||||
full = [self._ready_issue(n) for n in range(600, 700)]
|
||||
sliced = sorted(full, key=lambda c: -c.number)[:50]
|
||||
self.assertNotEqual(
|
||||
self._preview(full)["selected"]["number"],
|
||||
self._preview(sliced)["selected"]["number"],
|
||||
)
|
||||
|
||||
def test_blocked_first_candidate_falls_through_to_next(self) -> None:
|
||||
"""AC8: a blocked winner must not end the iteration."""
|
||||
blocked = self._ready_issue(
|
||||
600,
|
||||
dependency_unmet=True,
|
||||
dependency_reason="issue#600 depends on unresolved issue(s) #599",
|
||||
)
|
||||
result = self._preview([blocked, self._ready_issue(601)])
|
||||
self.assertEqual(result["selected"]["number"], 601)
|
||||
skipped = {s["number"] for s in result["skipped"]}
|
||||
self.assertIn(600, skipped)
|
||||
|
||||
def test_all_blocked_yields_no_safe_work(self) -> None:
|
||||
candidates = [
|
||||
self._ready_issue(
|
||||
n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
|
||||
)
|
||||
for n in range(600, 605)
|
||||
]
|
||||
result = self._preview(candidates)
|
||||
self.assertIsNone(result["selected"])
|
||||
self.assertEqual(len(result["skipped"]), 5)
|
||||
|
||||
def test_dry_run_and_apply_select_identically(self) -> None:
|
||||
"""AC9: apply mode must not re-rank differently from preview."""
|
||||
candidates = [self._ready_issue(n) for n in range(600, 700)]
|
||||
preview = self._preview(candidates)
|
||||
applied = allocate_next_work(
|
||||
self.db,
|
||||
session_id="s-758-apply",
|
||||
role="author",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=candidates,
|
||||
apply=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
preview["selected"]["number"], applied["selected"]["number"]
|
||||
)
|
||||
|
||||
def test_sort_is_stable_and_documented(self) -> None:
|
||||
ordered = sort_candidates(
|
||||
[self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
|
||||
)
|
||||
self.assertEqual([c.number for c in ordered], [601, 602, 603])
|
||||
self.assertIn("never affect selection", SELECTION_POLICY)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Allocator ownership exclusion tests (#765).
|
||||
|
||||
One session's active lease must never blockade the author queue for a
|
||||
different controller. Covers: foreign lease skipped, next unclaimed candidate
|
||||
selected, own task resumable, task-local blocker quarantined, all-claimed ->
|
||||
wait, same profile + different controller_instance_id -> different ownership,
|
||||
and claimed candidates reported in skipped results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from allocator_service import (
|
||||
OUTCOME_OWNERSHIP_DEFECT,
|
||||
OUTCOME_PREVIEW,
|
||||
OUTCOME_WAIT,
|
||||
OWNERSHIP_FOREIGN,
|
||||
OWNERSHIP_OWN,
|
||||
OWNERSHIP_UNKNOWN,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_claim_ownership,
|
||||
resolve_controller_instance_id,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
MINE = "ctl-mine-0001"
|
||||
THEIRS = "ctl-theirs-0002"
|
||||
|
||||
|
||||
def _issue(number: int, **kwargs) -> WorkCandidate:
|
||||
base = dict(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=f"issue {number}",
|
||||
priority=20,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return WorkCandidate(**base)
|
||||
|
||||
|
||||
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
|
||||
return {
|
||||
"lease_id": f"lease-{number}",
|
||||
"session_id": session_id,
|
||||
"controller_instance_id": instance,
|
||||
"role": "author",
|
||||
"profile": "prgs-author",
|
||||
"expires_at": "2026-07-20T07:06:09Z",
|
||||
"work_kind": kind,
|
||||
"work_number": number,
|
||||
}
|
||||
|
||||
|
||||
class AllocatorOwnershipTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _allocate(
|
||||
self,
|
||||
candidates,
|
||||
*,
|
||||
claims,
|
||||
session_id="sess-mine",
|
||||
instance=MINE,
|
||||
apply=False,
|
||||
role="author",
|
||||
):
|
||||
return allocate_next_work(
|
||||
self.db,
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=candidates,
|
||||
apply=apply,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=instance,
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
class TestOwnershipClassification(AllocatorOwnershipTestCase):
|
||||
def test_no_claim_returns_none(self):
|
||||
self.assertIsNone(
|
||||
classify_claim_ownership(
|
||||
None, session_id="s", controller_instance_id=MINE
|
||||
)
|
||||
)
|
||||
|
||||
def test_same_controller_instance_is_own(self):
|
||||
claim = _claim(1, session_id="other-session", instance=MINE)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_OWN,
|
||||
)
|
||||
|
||||
def test_same_profile_different_instance_is_foreign(self):
|
||||
"""Shared profile must not imply shared ownership."""
|
||||
claim = _claim(1, session_id="other-session", instance=THEIRS)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_FOREIGN,
|
||||
)
|
||||
|
||||
def test_exact_session_match_is_own(self):
|
||||
claim = _claim(1, session_id="sess-mine", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_OWN,
|
||||
)
|
||||
|
||||
def test_legacy_claim_with_neither_side_identified_is_foreign(self):
|
||||
"""No identities anywhere: a different session id is simply not ours."""
|
||||
claim = _claim(1, session_id="someone-else", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_FOREIGN,
|
||||
)
|
||||
|
||||
def test_claim_identified_but_local_undeclared_is_unknown(self):
|
||||
"""Only one side identified: not comparable, so never adopt."""
|
||||
claim = _claim(1, session_id="someone-else", instance=THEIRS)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_UNKNOWN,
|
||||
)
|
||||
|
||||
def test_local_identified_but_claim_undeclared_is_unknown(self):
|
||||
"""A legacy lease may be our own under an old session id; do not guess."""
|
||||
claim = _claim(1, session_id="someone-else", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_UNKNOWN,
|
||||
)
|
||||
|
||||
def test_resolve_controller_instance_id_reads_env(self):
|
||||
self.assertEqual(
|
||||
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": MINE}),
|
||||
MINE,
|
||||
)
|
||||
self.assertIsNone(resolve_controller_instance_id({}))
|
||||
self.assertIsNone(
|
||||
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": " "})
|
||||
)
|
||||
|
||||
|
||||
class TestForeignLeaseDoesNotBlockade(AllocatorOwnershipTestCase):
|
||||
def test_foreign_claim_skipped_and_next_issue_selected(self):
|
||||
"""Skip the claimed issue, select the next unclaimed one."""
|
||||
candidates = [_issue(607), _issue(615), _issue(617)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
|
||||
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
skipped_607 = [s for s in result["skipped"] if s["number"] == 607]
|
||||
self.assertEqual(len(skipped_607), 1)
|
||||
self.assertEqual(
|
||||
skipped_607[0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
|
||||
)
|
||||
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, skipped_607[0]["reason"])
|
||||
|
||||
def test_claimed_candidate_appears_in_skipped_inventory(self):
|
||||
"""Skipped reporting must reflect claimed candidates."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(len(result["skipped"]), 1)
|
||||
self.assertEqual(len(result["claims_excluded"]), 1)
|
||||
excluded = result["claims_excluded"][0]
|
||||
self.assertEqual(excluded["number"], 607)
|
||||
self.assertEqual(excluded["ownership"], OWNERSHIP_FOREIGN)
|
||||
self.assertEqual(excluded["owner_controller_instance_id"], THEIRS)
|
||||
|
||||
def test_task_local_blocker_does_not_freeze_unrelated_work(self):
|
||||
"""A quarantined task must not stop the rest of the queue."""
|
||||
candidates = [_issue(607), _issue(615), _issue(617)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
first = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(first["selected"]["number"], 615)
|
||||
|
||||
# 615 then gets claimed by yet another controller; queue still advances.
|
||||
claims[("issue", 615)] = _claim(
|
||||
615, session_id="sess-third", instance="ctl-third-0003"
|
||||
)
|
||||
second = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(second["selected"]["number"], 617)
|
||||
|
||||
def test_multiple_controllers_get_different_issues(self):
|
||||
"""Concurrent author sessions work on different issues."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
mine = self._allocate(candidates, claims=claims, instance=MINE)
|
||||
theirs = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-theirs", instance=THEIRS
|
||||
)
|
||||
self.assertEqual(mine["selected"]["number"], 615)
|
||||
# The other controller may still be handed its own in-progress task.
|
||||
self.assertEqual(theirs["selected"]["number"], 607)
|
||||
|
||||
def test_unclaimed_queue_is_unaffected(self):
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
result = self._allocate(candidates, claims={})
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
self.assertEqual(result["skipped"], [])
|
||||
self.assertEqual(result["claims_excluded"], [])
|
||||
|
||||
|
||||
class TestOwnTaskResume(AllocatorOwnershipTestCase):
|
||||
def test_controller_may_resume_its_own_active_task(self):
|
||||
"""Own claim stays selectable across a new session id."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-mine-old", instance=MINE)
|
||||
}
|
||||
result = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-mine-new", instance=MINE
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
self.assertEqual(result["claims_excluded"], [])
|
||||
|
||||
def test_own_claim_by_exact_session_is_selectable(self):
|
||||
candidates = [_issue(607)]
|
||||
claims = {("issue", 607): _claim(607, session_id="sess-mine", instance=None)}
|
||||
result = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-mine", instance=None
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
|
||||
|
||||
class TestAllCandidatesClaimed(AllocatorOwnershipTestCase):
|
||||
def test_all_claimed_returns_wait_not_a_claimed_selection(self):
|
||||
"""Never hand back a claimed issue; report waiting instead."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-a", instance=THEIRS),
|
||||
("issue", 615): _claim(615, session_id="sess-b", instance="ctl-c-0003"),
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertIsNone(result["selected"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_WAIT)
|
||||
self.assertEqual(len(result["claims_excluded"]), 2)
|
||||
|
||||
def test_unidentifiable_owner_reports_ownership_defect(self):
|
||||
"""Refuse to adopt when ownership cannot be established."""
|
||||
candidates = [_issue(607)]
|
||||
claims = {("issue", 607): _claim(607, session_id="sess-legacy", instance=None)}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertIsNone(result["selected"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_OWNERSHIP_DEFECT)
|
||||
self.assertEqual(len(result["ownership_defects"]), 1)
|
||||
self.assertEqual(
|
||||
result["ownership_defects"][0]["ownership"], OWNERSHIP_UNKNOWN
|
||||
)
|
||||
|
||||
|
||||
class TestClaimsFromControlPlaneDb(AllocatorOwnershipTestCase):
|
||||
"""End-to-end against the real substrate, not injected claim dicts."""
|
||||
|
||||
def _seed_lease(self, number: int, *, session_id: str, instance: str | None):
|
||||
self.db.upsert_session(
|
||||
session_id=session_id,
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=4242,
|
||||
controller_instance_id=instance,
|
||||
)
|
||||
return self.db.assign_and_lease(
|
||||
session_id=session_id,
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
kind="issue",
|
||||
number=number,
|
||||
)
|
||||
|
||||
def test_controller_instance_id_persists_on_session(self):
|
||||
row = self.db.upsert_session(
|
||||
session_id="sess-x",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=1,
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(row["controller_instance_id"], MINE)
|
||||
|
||||
def test_heartbeat_without_instance_does_not_erase_ownership(self):
|
||||
self.db.upsert_session(
|
||||
session_id="sess-x",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=1,
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
row = self.db.upsert_session(
|
||||
session_id="sess-x", role="author", profile="prgs-author", pid=1
|
||||
)
|
||||
self.assertEqual(row["controller_instance_id"], MINE)
|
||||
|
||||
def test_list_active_claims_surfaces_owner_instance(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
claims = self.db.list_active_claims(remote=REMOTE, org=ORG, repo=REPO)
|
||||
self.assertIn(("issue", 607), claims)
|
||||
self.assertEqual(claims[("issue", 607)]["controller_instance_id"], THEIRS)
|
||||
|
||||
def test_live_foreign_lease_is_excluded_without_injected_claims(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
result = allocate_next_work(
|
||||
self.db,
|
||||
session_id="sess-mine",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=[_issue(607), _issue(615)],
|
||||
apply=False,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
self.assertEqual(
|
||||
result["skipped"][0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
|
||||
)
|
||||
|
||||
def test_apply_reserves_the_unclaimed_issue(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
result = allocate_next_work(
|
||||
self.db,
|
||||
session_id="sess-mine",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=[_issue(607), _issue(615)],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(result["outcome"], "assigned_work")
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
self.assertEqual(result["assignment"]["work_number"], 615)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""MCP-level allocator inventory and dependency regressions (#758).
|
||||
|
||||
Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
|
||||
tool end to end against a faked Gitea API, proving:
|
||||
|
||||
* the complete open-issue inventory is ranked (no pre-ranking truncation);
|
||||
* ``limit`` cannot change which candidate wins;
|
||||
* canonical ``Depends:`` declarations are resolved from live issue state;
|
||||
* unavailable dependency evidence fails closed;
|
||||
* an incomplete listing fails closed instead of ranking a partial set.
|
||||
|
||||
Issue numbers here are synthetic; no production number is special-cased.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
FAKE_AUTH = "token REDACTED"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
|
||||
def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
|
||||
return {
|
||||
"number": number,
|
||||
"title": f"issue {number}",
|
||||
"body": body,
|
||||
"labels": [{"name": name} for name in labels],
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
|
||||
def _depends_body(*refs: int) -> str:
|
||||
joined = ", ".join(f"#{r}" for r in refs)
|
||||
return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"
|
||||
|
||||
|
||||
class _FakeGitea:
|
||||
"""Minimal stand-in for the two Gitea list endpoints plus issue lookups."""
|
||||
|
||||
def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
|
||||
self.issues = list(issues)
|
||||
self.closed = set(closed)
|
||||
self.unavailable = set(unavailable)
|
||||
self.fail_issue_list = fail_issue_list
|
||||
self.lookups: list[int] = []
|
||||
|
||||
def api_get_all(self, url, _auth, **_kw):
|
||||
if "/pulls" in url:
|
||||
return []
|
||||
if self.fail_issue_list:
|
||||
raise RuntimeError("issue listing failed")
|
||||
return list(self.issues)
|
||||
|
||||
def api_request(self, _method, url, _auth, **_kw):
|
||||
number = int(url.rsplit("/", 1)[-1])
|
||||
self.lookups.append(number)
|
||||
if number in self.unavailable:
|
||||
raise RuntimeError("lookup failed")
|
||||
if number in self.closed:
|
||||
return {"number": number, "state": "closed"}
|
||||
return {"number": number, "state": "open"}
|
||||
|
||||
|
||||
class AllocatorInventoryTest(unittest.TestCase):
|
||||
"""Direct tests of the candidate loader."""
|
||||
|
||||
def _load(self, fake, **kwargs):
|
||||
with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
|
||||
"gitea_mcp_server._auth", return_value=FAKE_AUTH
|
||||
), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
|
||||
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||
):
|
||||
return srv._allocator_candidates_from_gitea(
|
||||
remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
|
||||
)
|
||||
|
||||
def test_full_inventory_above_fifty_is_ranked(self) -> None:
|
||||
"""AC1: all 73 open issues become candidates, not the first 50."""
|
||||
fake = _FakeGitea([_issue(n) for n in range(600, 673)])
|
||||
candidates, _reasons, complete = self._load(fake)
|
||||
self.assertTrue(complete)
|
||||
self.assertEqual(len(candidates), 73)
|
||||
self.assertEqual(min(c.number for c in candidates), 600)
|
||||
self.assertEqual(max(c.number for c in candidates), 672)
|
||||
|
||||
def test_open_dependency_marks_candidate_unmet(self) -> None:
|
||||
"""AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
|
||||
fake = _FakeGitea(
|
||||
[_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
|
||||
)
|
||||
candidates, _reasons, _complete = self._load(fake)
|
||||
blocked = next(c for c in candidates if c.number == 600)
|
||||
self.assertTrue(blocked.dependency_unmet)
|
||||
self.assertIn("#601", blocked.dependency_reason)
|
||||
|
||||
def test_closed_dependency_is_eligible(self) -> None:
|
||||
"""A dependency absent from the open list is confirmed closed, not assumed."""
|
||||
fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
|
||||
candidates, _reasons, _complete = self._load(fake)
|
||||
candidate = next(c for c in candidates if c.number == 600)
|
||||
self.assertFalse(candidate.dependency_unmet)
|
||||
self.assertIn(500, fake.lookups) # proved live, not inferred
|
||||
|
||||
def test_unavailable_dependency_evidence_fails_closed(self) -> None:
|
||||
"""AC7: an unreachable dependency must block, never pass."""
|
||||
fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
|
||||
candidates, _reasons, _complete = self._load(fake)
|
||||
candidate = next(c for c in candidates if c.number == 600)
|
||||
self.assertTrue(candidate.dependency_unmet)
|
||||
self.assertIn("fail closed", candidate.dependency_reason)
|
||||
|
||||
def test_dependency_state_lookups_are_cached(self) -> None:
|
||||
"""Repeated references resolve with a single live lookup."""
|
||||
fake = _FakeGitea(
|
||||
[_issue(n, body=_depends_body(500)) for n in range(600, 610)],
|
||||
closed={500},
|
||||
)
|
||||
self._load(fake)
|
||||
self.assertEqual(fake.lookups.count(500), 1)
|
||||
|
||||
def test_open_dependency_needs_no_lookup(self) -> None:
|
||||
"""The complete open listing already proves openness."""
|
||||
fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
|
||||
self._load(fake)
|
||||
self.assertNotIn(601, fake.lookups)
|
||||
|
||||
def test_failed_issue_listing_reports_incomplete(self) -> None:
|
||||
"""AC3: a failed listing must not silently yield a short inventory."""
|
||||
fake = _FakeGitea([], fail_issue_list=True)
|
||||
_candidates, reasons, complete = self._load(fake)
|
||||
self.assertFalse(complete)
|
||||
self.assertTrue(any("failed to list open issues" in r for r in reasons))
|
||||
|
||||
|
||||
class AllocateNextWorkToolTest(unittest.TestCase):
|
||||
"""End-to-end tests of the gitea_allocate_next_work MCP tool."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _allocate(self, fake, **kwargs):
|
||||
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
|
||||
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
|
||||
), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
|
||||
"gitea_mcp_server.get_profile",
|
||||
return_value={"profile_name": "prgs-author", "role": "author"},
|
||||
), patch(
|
||||
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
|
||||
), patch(
|
||||
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||
), patch(
|
||||
"gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
|
||||
), patch(
|
||||
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||
), patch(
|
||||
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||
):
|
||||
return srv.gitea_allocate_next_work(
|
||||
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||
)
|
||||
|
||||
def test_limit_does_not_change_selection(self) -> None:
|
||||
"""AC2/AC11: the winner is identical at limit=1 and limit=300."""
|
||||
issues = [_issue(n) for n in range(600, 673)] # 73 candidates
|
||||
low = self._allocate(_FakeGitea(issues), limit=1)
|
||||
high = self._allocate(_FakeGitea(issues), limit=300)
|
||||
self.assertEqual(low["selected"]["number"], high["selected"]["number"])
|
||||
self.assertEqual(low["selected"]["number"], 600)
|
||||
self.assertEqual(low["candidate_count"], 73)
|
||||
self.assertEqual(high["candidate_count"], 73)
|
||||
|
||||
def test_dependency_blocked_winner_falls_through(self) -> None:
|
||||
"""AC8: a blocked highest-ranked issue yields the next eligible one."""
|
||||
issues = [
|
||||
_issue(600, body=_depends_body(601)),
|
||||
_issue(601),
|
||||
_issue(602),
|
||||
]
|
||||
result = self._allocate(_FakeGitea(issues))
|
||||
# 600 is blocked by open 601; 601 itself is a valid candidate.
|
||||
self.assertEqual(result["selected"]["number"], 601)
|
||||
skipped = {s["number"] for s in result["skipped"]}
|
||||
self.assertIn(600, skipped)
|
||||
|
||||
def test_limit_truncates_only_the_reported_skip_list(self) -> None:
|
||||
"""A shortened report is labelled, never presented as full coverage."""
|
||||
issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
|
||||
issues.append(_issue(999)) # open dependency blocks all of the above
|
||||
issues.append(_issue(700)) # the one eligible candidate
|
||||
result = self._allocate(_FakeGitea(issues), limit=5)
|
||||
self.assertTrue(result["skipped_report_truncated"])
|
||||
self.assertEqual(len(result["skipped"]), 5)
|
||||
self.assertGreater(result["skipped_total"], 5)
|
||||
self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")
|
||||
|
||||
def test_incomplete_inventory_fails_closed(self) -> None:
|
||||
"""AC3: no selection is made from a partial candidate set."""
|
||||
result = self._allocate(_FakeGitea([], fail_issue_list=True))
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["inventory_complete"])
|
||||
self.assertIsNone(result["assignment"])
|
||||
self.assertTrue(
|
||||
any("fail closed" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_selection_policy_is_reported(self) -> None:
|
||||
"""AC10: tie-breaking is stated in the result, not left implicit."""
|
||||
result = self._allocate(_FakeGitea([_issue(600)]))
|
||||
self.assertIn("selection_policy", result)
|
||||
self.assertIn("number asc", result["selection_policy"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Allocator pre-rank exclusions and candidates_json transport (#776).
|
||||
|
||||
Covers:
|
||||
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
|
||||
* excluded top candidate selects the next safe candidate;
|
||||
* all candidates excluded → WAIT, no lease;
|
||||
* decoded-list and JSON-string candidates_json;
|
||||
* malformed / type-invalid fail-closed cases;
|
||||
* dry-run/apply fingerprint match and drift rejection;
|
||||
* foreign lease and same-owner lease on excluded issue;
|
||||
* skipped-accounting reason parity (excluded_by_controller);
|
||||
* public MCP entry-point coverage for exclude_issue_numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
from allocator_service import (
|
||||
OUTCOME_ASSIGNED,
|
||||
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
OUTCOME_CANDIDATE_SET_DRIFT,
|
||||
OUTCOME_NO_SAFE,
|
||||
OUTCOME_PREVIEW,
|
||||
OUTCOME_WAIT,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
candidate_from_dict,
|
||||
candidate_set_fingerprint,
|
||||
normalize_candidates_payload,
|
||||
normalize_exclude_issue_numbers,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
MINE = "ctl-mine-776"
|
||||
THEIRS = "ctl-theirs-776"
|
||||
|
||||
|
||||
def _issue(number: int, **kwargs) -> WorkCandidate:
|
||||
base = dict(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=f"issue {number}",
|
||||
priority=20,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return WorkCandidate(**base)
|
||||
|
||||
|
||||
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
|
||||
return {
|
||||
"lease_id": f"lease-{number}",
|
||||
"session_id": session_id,
|
||||
"controller_instance_id": instance,
|
||||
"role": "author",
|
||||
"profile": "prgs-author",
|
||||
"expires_at": "2026-07-21T12:00:00Z",
|
||||
"work_kind": kind,
|
||||
"work_number": number,
|
||||
}
|
||||
|
||||
|
||||
def _cand_dict(number: int, **kwargs) -> dict:
|
||||
d = {
|
||||
"kind": "issue",
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"labels": ["status:ready", "type:bug"],
|
||||
"title": f"issue {number}",
|
||||
"priority": 20,
|
||||
}
|
||||
d.update(kwargs)
|
||||
return d
|
||||
|
||||
|
||||
class AllocatorExcludeServiceTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _alloc(self, candidates, **kwargs):
|
||||
defaults = dict(
|
||||
session_id="sess-776",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
claims={},
|
||||
apply=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||
|
||||
def test_exclude_617_before_ranking_never_selects(self) -> None:
|
||||
"""AC2/AC8: highest-ranked #617 is removed before ranking."""
|
||||
cands = [_issue(617), _issue(700), _issue(701)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[617])
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertIn(617, skipped)
|
||||
self.assertEqual(
|
||||
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||
)
|
||||
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])
|
||||
|
||||
def test_excluded_top_selects_next_safe(self) -> None:
|
||||
"""AC2: excluding the oldest ready issue promotes the next number."""
|
||||
cands = [_issue(600), _issue(601), _issue(602)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[600])
|
||||
self.assertEqual(res["selected"]["number"], 601)
|
||||
|
||||
def test_all_candidates_excluded_wait_no_lease(self) -> None:
|
||||
"""AC7: every candidate excluded → WAIT, no assignment."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
|
||||
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||
self.assertIsNone(res["selected"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertEqual(len(res["controller_excluded"]), 2)
|
||||
|
||||
def test_omit_exclude_retains_existing_behavior(self) -> None:
|
||||
"""AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(cands)
|
||||
self.assertEqual(res["selected"]["number"], 617)
|
||||
self.assertEqual(res.get("exclude_issue_numbers"), [])
|
||||
|
||||
def test_foreign_lease_still_skipped(self) -> None:
|
||||
"""AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
claims = {
|
||||
("issue", 700): _claim(700, session_id="other", instance=THEIRS),
|
||||
}
|
||||
res = self._alloc(
|
||||
cands, exclude_issue_numbers=[617], claims=claims
|
||||
)
|
||||
# 617 excluded, 700 foreign → wait, no selection
|
||||
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||
self.assertIsNone(res["selected"])
|
||||
codes = {s["reason_code"] for s in res["skipped"]}
|
||||
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
|
||||
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)
|
||||
|
||||
def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
|
||||
"""AC5: excluded + live same-owner lease → structured blocker."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
claims = {
|
||||
("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
|
||||
}
|
||||
res = self._alloc(
|
||||
cands, exclude_issue_numbers=[617], claims=claims, apply=True
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertEqual(res["blocked_lease"]["number"], 617)
|
||||
self.assertIn("resume", res["blocked_lease"]["safe_next_action"])
|
||||
|
||||
def test_dry_run_apply_fingerprint_match(self) -> None:
|
||||
"""AC4: dry-run and apply share the same fingerprint."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
|
||||
apply_res = self._alloc(
|
||||
cands,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(
|
||||
dry["candidate_set_fingerprint"],
|
||||
apply_res["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||
|
||||
def test_apply_rejects_fingerprint_drift(self) -> None:
|
||||
"""AC4: material candidate-set drift fails closed on apply."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(
|
||||
cands,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint="0" * 64,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
|
||||
self.assertIsNone(res["assignment"])
|
||||
|
||||
def test_fingerprint_stable_helper(self) -> None:
|
||||
cands = [_issue(700), _issue(617)]
|
||||
a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
|
||||
b = candidate_set_fingerprint(
|
||||
list(reversed(cands)), exclude_issue_numbers=[617]
|
||||
)
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_normalize_exclude_rejects_bool(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_exclude_issue_numbers([True])
|
||||
|
||||
def test_normalize_exclude_rejects_scalar(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_exclude_issue_numbers(617)
|
||||
|
||||
|
||||
class CandidatesJsonNormalizeTest(unittest.TestCase):
|
||||
def test_decoded_list(self) -> None:
|
||||
"""AC3: already-decoded list from MCP transport."""
|
||||
cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
|
||||
self.assertEqual([c.number for c in cands], [617, 700])
|
||||
|
||||
def test_json_string(self) -> None:
|
||||
"""AC3: backward-compatible JSON string."""
|
||||
raw = json.dumps([_cand_dict(617)])
|
||||
cands = normalize_candidates_payload(raw)
|
||||
self.assertEqual(cands[0].number, 617)
|
||||
|
||||
def test_malformed_json_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
normalize_candidates_payload("{not json")
|
||||
self.assertIn("malformed", str(ctx.exception).lower())
|
||||
|
||||
def test_scalar_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(42)
|
||||
|
||||
def test_bool_number_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload([_cand_dict(True)]) # type: ignore[arg-type]
|
||||
|
||||
def test_invalid_record_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(["not-a-dict"])
|
||||
|
||||
def test_object_not_list_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(json.dumps({"number": 1}))
|
||||
|
||||
def test_candidate_from_dict_rejects_bool_number(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
candidate_from_dict({"kind": "issue", "number": True})
|
||||
|
||||
|
||||
class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
|
||||
"""Public MCP entry-point coverage (#776 AC8)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _call(self, **kwargs):
|
||||
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
|
||||
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
|
||||
), patch(
|
||||
"gitea_mcp_server.get_profile",
|
||||
return_value={"profile_name": "prgs-author", "role": "author"},
|
||||
), patch(
|
||||
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
|
||||
), patch(
|
||||
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||
), patch(
|
||||
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||
):
|
||||
return srv.gitea_allocate_next_work(
|
||||
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||
)
|
||||
|
||||
def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
|
||||
"""AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
|
||||
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||
res = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=False,
|
||||
)
|
||||
self.assertTrue(res.get("success"), res)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertEqual(
|
||||
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||
)
|
||||
self.assertNotEqual(res["selected"]["number"], 617)
|
||||
|
||||
def test_mcp_exclude_617_json_string_apply(self) -> None:
|
||||
"""AC8: JSON-string transport + apply never leases #617."""
|
||||
raw = json.dumps([_cand_dict(617), _cand_dict(700)])
|
||||
res = self._call(
|
||||
candidates_json=raw,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["assignment"]["work_number"], 700)
|
||||
self.assertNotEqual(res["selected"]["number"], 617)
|
||||
|
||||
def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
|
||||
res = self._call(candidates_json="{bad", apply=False)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
|
||||
|
||||
def test_mcp_bool_number_fail_closed(self) -> None:
|
||||
res = self._call(
|
||||
candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
|
||||
apply=False,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
|
||||
def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
|
||||
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||
dry = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=False,
|
||||
)
|
||||
apply_res = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(
|
||||
dry["candidate_set_fingerprint"],
|
||||
apply_res["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||
|
||||
import os
|
||||
|
||||
+57
-17
@@ -1,3 +1,7 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||
|
||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
||||
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||
"GITEA_PROFILE_NAME", None
|
||||
) or "test-author-prgs"
|
||||
env = shared_mutation_env(
|
||||
profile,
|
||||
GITEA_AUDIT_LOG=self.audit_path,
|
||||
GITEA_TOKEN_TEST="test-token",
|
||||
)
|
||||
# Strip legacy env-only authority knobs if callers still pass them;
|
||||
# config-backed profiles own operations and repositories (#714).
|
||||
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||
extra.pop("GITEA_PROFILE_NAME", None)
|
||||
env.update(extra)
|
||||
env["GITEA_MCP_PROFILE"] = profile
|
||||
return env
|
||||
|
||||
def _records(self):
|
||||
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
rec = recs[0]
|
||||
self.assertEqual(rec["action"], "create_issue")
|
||||
self.assertEqual(rec["result"], "succeeded")
|
||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
||||
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||
self.assertEqual(rec["issue_number"], 11)
|
||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||
@@ -222,7 +238,17 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_close_issue_audited(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"state": "closed"}, {"login": "mgr-bot"}]
|
||||
# Keyed rather than positional: closing an issue also reads its labels
|
||||
# before and after the state change for the #780 terminal cleanup and
|
||||
# its read-after-write check, so call order is not a fixed sequence.
|
||||
def api(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
return {"state": "closed"}
|
||||
if "/issues/" in url:
|
||||
return {"number": 42, "labels": []}
|
||||
return {"login": "mgr-bot"}
|
||||
|
||||
mock_api.side_effect = api
|
||||
with patch.dict(os.environ, self._env(), clear=True):
|
||||
gitea_close_issue(issue_number=42, remote="prgs")
|
||||
recs = self._records()
|
||||
@@ -239,10 +265,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
||||
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}, clear=True):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
shared_mutation_env("test-author-prgs"),
|
||||
clear=True,
|
||||
):
|
||||
gitea_create_issue(title="x", remote="prgs")
|
||||
issue_posts = [
|
||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||
@@ -342,8 +369,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
self._pr("author-bot"), approval, # gate 7 feedback
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||
@@ -362,8 +388,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||
# Self-author merge is blocked; must still be recorded as blocked.
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||
@@ -385,11 +410,23 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
import session_context_binding as _sc
|
||||
from tests.test_mcp_server import (
|
||||
_init_reviewer_session,
|
||||
_install_owned_reviewer_lease,
|
||||
)
|
||||
from mcp_server import gitea_mark_final_review_decision
|
||||
|
||||
# Rebind after profile env is applied so durable session state
|
||||
# matches the active reviewer profile (#714 / #695).
|
||||
_sc._reset_session_context_for_testing()
|
||||
_init_reviewer_session("prgs")
|
||||
lease = _install_owned_reviewer_lease(8)
|
||||
lease.start()
|
||||
self.addCleanup(lease.stop)
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
gitea_mark_final_review_decision(
|
||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||
)
|
||||
@@ -398,7 +435,10 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(r["performed"])
|
||||
self.assertTrue(
|
||||
r["performed"],
|
||||
msg=f"submit_pr_review blocked: {r}",
|
||||
)
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||
|
||||
@@ -26,8 +26,9 @@ from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "prgs-author-delete",
|
||||
"role": "author",
|
||||
# #729: delete_branch is reconciler-owned.
|
||||
"profile_name": "prgs-reconciler-delete",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
@@ -238,7 +239,7 @@ class TestMcpGates(unittest.TestCase):
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
||||
@@ -278,7 +279,7 @@ class TestMcpGates(unittest.TestCase):
|
||||
after_state="gone",
|
||||
)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
|
||||
@@ -79,18 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree",
|
||||
return_value=None,
|
||||
):
|
||||
with mock.patch(
|
||||
"gitea_auth.get_profile",
|
||||
return_value={"profile_name": "gitea-author"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
blob = str(ctx.exception)
|
||||
self.assertTrue(
|
||||
"Branches-only mutation guard" in blob
|
||||
or "control checkout" in blob
|
||||
or "author worktree" in blob.lower()
|
||||
or "#618" in blob,
|
||||
msg=blob,
|
||||
)
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
import mcp_server
|
||||
@@ -98,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||
healthy_ctx = {
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"workspace_role_kind": "author",
|
||||
"ignored_bindings": [],
|
||||
"process_project_root": "/repo/Gitea-Tools",
|
||||
"canonical_repo_root": "/repo/Gitea-Tools",
|
||||
"roots_aligned": True,
|
||||
"bound_worktree_missing": False,
|
||||
"author_worktree_block": False,
|
||||
"author_worktree_reasons": [],
|
||||
"author_worktree_resolution": {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"reasons": [],
|
||||
},
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": True,
|
||||
"inspected_git_root": worktree,
|
||||
}
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch.object(
|
||||
mcp_server, "_session_author_lock_worktree", return_value=None
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
with mock.patch.object(
|
||||
mcp_server,
|
||||
"_resolve_namespace_mutation_context",
|
||||
return_value=healthy_ctx,
|
||||
):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -257,22 +257,27 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
def test_reconciler_with_branch_delete_cannot_raw_delete(self):
|
||||
"""#687: reconciler + gitea.branch.delete still cannot call raw delete."""
|
||||
def test_reconciler_with_branch_delete_can_raw_delete(self):
|
||||
"""#729: delete_branch is re-homed from author to reconciler. The
|
||||
reconciler is the delete-capable role and performs raw deletion of an
|
||||
eligible (non-preservation, non-protected) branch — superseding the
|
||||
#687 redirect that previously blocked it."""
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value=dict(RECONCILER_WITH_DELETE),
|
||||
).start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
res = gitea_delete_branch(
|
||||
branch="fix/issue-683-workflow-guard-hardening",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertFalse(res.get("performed", True))
|
||||
reasons = " ".join(res.get("reasons") or [])
|
||||
self.assertIn("raw gitea_delete_branch", reasons)
|
||||
self.assertIn("cleanup_merged_pr_branch", reasons)
|
||||
self.mock_api.assert_not_called()
|
||||
self.assertTrue(res.get("success"))
|
||||
delete_calls = [
|
||||
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_reconciler_raw_delete_denies_preservation_branch(self):
|
||||
patch(
|
||||
@@ -286,17 +291,18 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
self.assertFalse(res.get("performed", True))
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_author_with_branch_delete_role_ok_but_preserve_blocked(self):
|
||||
"""Author role may use raw delete path when permitted; preserve fails closed."""
|
||||
def test_reconciler_raw_delete_role_ok_but_preserve_blocked(self):
|
||||
"""#729: reconciler role may use raw delete path when permitted;
|
||||
preservation branch still fails closed."""
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
|
||||
@@ -29,6 +29,7 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": ["gitea.pr.create"],
|
||||
"execution_profile": "commit-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"pr-only-author": {
|
||||
"enabled": True,
|
||||
@@ -39,6 +40,7 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": ["gitea.repo.commit"],
|
||||
"execution_profile": "pr-only-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
@@ -51,6 +53,7 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,6 +35,7 @@ CONFIG = {
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-no-commit": {
|
||||
"enabled": True,
|
||||
@@ -49,6 +50,7 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||
],
|
||||
"execution_profile": "reviewer-no-commit",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,6 +35,7 @@ CONFIG = {
|
||||
"gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||
its integration into gitea_auth.get_profile / get_auth_header.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
|
||||
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(rows["schema_version"], "3")
|
||||
self.assertEqual(rows["schema_version"], "4")
|
||||
self.assertIn("DB coordinates", rows["architecture"])
|
||||
self.assertIn("bridge", rows["architecture"].lower())
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Tests for create_issue.py.
|
||||
|
||||
Every test mocks auth functions so no real network calls or keychain
|
||||
@@ -12,7 +13,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
|
||||
# The module under test lives in the repo root, not a package.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(_SKIP, _REASON)
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
"""Ensure the JSON payload sent to Gitea is correct."""
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Regression tests for create_issue bootstrap (#749).
|
||||
|
||||
TDD: these tests define the sanctioned first-mutation path for
|
||||
``gitea_create_issue`` from a clean canonical control checkout, and prove
|
||||
the exemption cannot widen to dirty roots, foreign clones, arbitrary
|
||||
``branches/`` directories, or post-creation author mutations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import create_issue_bootstrap as cib # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import workflow_scope_guard as wsg # noqa: E402
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
MASTER_SHA = "a" * 40
|
||||
STALE_SHA = "b" * 40
|
||||
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
|
||||
|
||||
class TestCreateIssueBootstrapAssessor(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def test_non_create_issue_task_not_applicable(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="master",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="lock_issue",
|
||||
)
|
||||
self.assertTrue(res["not_applicable"])
|
||||
self.assertFalse(res["allowed"])
|
||||
self.assertFalse(res["block"])
|
||||
|
||||
def test_branches_worktree_not_applicable(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=f"{self.ROOT}/branches/issue-1-x",
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="fix/issue-1-x",
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["not_applicable"])
|
||||
self.assertFalse(res["allowed"])
|
||||
|
||||
def test_clean_control_checkout_allowed(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="master",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertFalse(res["not_applicable"])
|
||||
self.assertTrue(res["allowed"])
|
||||
self.assertFalse(res["block"])
|
||||
self.assertEqual(res["bootstrap_path"], "clean_canonical_control_checkout")
|
||||
# Post-create next action must name issue-backed worktree after N exists.
|
||||
self.assertIn("branches/issue-<N>-*", res["exact_next_action"])
|
||||
|
||||
def test_tool_alias_gitea_create_issue_allowed(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="main",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="gitea_create_issue",
|
||||
)
|
||||
self.assertTrue(res["allowed"])
|
||||
|
||||
def test_dirty_control_checkout_blocked(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="master",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status=" M gitea_mcp_server.py\n",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
self.assertFalse(res["allowed"])
|
||||
self.assertTrue(any("tracked local edits" in r for r in res["reasons"]))
|
||||
# Pre-issue phase: next action must be satisfiable without inventing <N>.
|
||||
next_a = res["exact_next_action"] or ""
|
||||
self.assertIn("clean accepted base branch", next_a)
|
||||
self.assertIn("before the issue exists", next_a)
|
||||
# Must not prescribe "bind branches/issue-<N>" as the recovery step.
|
||||
self.assertNotIn("Bind an issue-backed worktree", next_a)
|
||||
|
||||
def test_stale_base_blocked(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="master",
|
||||
head_sha=STALE_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
self.assertTrue(any("live master" in r for r in res["reasons"]))
|
||||
|
||||
def test_non_base_branch_blocked(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="feat/something",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
|
||||
def test_detached_head_blocked(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=self.ROOT,
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
self.assertTrue(any("detached" in r for r in res["reasons"]))
|
||||
|
||||
def test_foreign_workspace_blocked(self):
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path="/other/clone",
|
||||
canonical_repo_root=self.ROOT,
|
||||
current_branch="master",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
self.assertTrue(any("canonical control checkout" in r for r in res["reasons"]))
|
||||
|
||||
|
||||
class TestCreateIssueBootstrapIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = "create_issue"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
|
||||
def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
|
||||
return {
|
||||
"current_branch": branch,
|
||||
"head_sha": head,
|
||||
"porcelain_status": porcelain,
|
||||
}
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
)
|
||||
def test_clean_control_checkout_create_issue_succeeds(
|
||||
self, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||
):
|
||||
mock_api.return_value = {
|
||||
"number": 99,
|
||||
"html_url": "https://gitea.example.com/issues/99",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=self._git_state(),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server._get_workspace_porcelain", return_value=""
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server._enforce_root_checkout_guard"
|
||||
):
|
||||
# Anti-stomp / master parity: keep gates green.
|
||||
with patch.object(
|
||||
srv,
|
||||
"_run_anti_stomp_preflight",
|
||||
return_value=None,
|
||||
):
|
||||
res = srv.gitea_create_issue(
|
||||
title="Bootstrap issue from clean control",
|
||||
body="Body text for content gate.",
|
||||
)
|
||||
self.assertEqual(res.get("number"), 99)
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
def test_dirty_control_checkout_create_issue_fails_closed(
|
||||
self, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||
):
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=self._git_state(
|
||||
porcelain=" M author_mutation_worktree.py\n"
|
||||
),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server._get_workspace_porcelain",
|
||||
return_value=" M author_mutation_worktree.py\n",
|
||||
):
|
||||
res = srv.gitea_create_issue(
|
||||
title="Should fail on dirty root",
|
||||
body="Body text for content gate.",
|
||||
)
|
||||
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||
if isinstance(res, dict) and res.get("success") is False:
|
||||
blob = " ".join(res.get("reasons") or [])
|
||||
self.assertTrue(
|
||||
"tracked local edits" in blob
|
||||
or "dirty" in blob.lower()
|
||||
or "control checkout" in blob.lower()
|
||||
or res.get("blocker_kind")
|
||||
)
|
||||
# Pre-issue phase must not demand issue-<N> worktree.
|
||||
next_a = res.get("exact_next_action") or ""
|
||||
if next_a:
|
||||
self.assertNotIn("issue-<N>-*", next_a)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
def test_lock_issue_still_requires_branches_worktree(self, _role, _ns, _prof, _auth):
|
||||
"""Existing issue-backed mutations receive no exemption (#749 AC3/AC7)."""
|
||||
srv._preflight_resolved_task = "lock_issue"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=self._git_state(),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server._get_workspace_porcelain", return_value=""
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
):
|
||||
with patch.object(
|
||||
srv, "_enforce_root_checkout_guard"
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity(
|
||||
remote="prgs",
|
||||
task="lock_issue",
|
||||
)
|
||||
msg = str(ctx.exception)
|
||||
self.assertTrue(
|
||||
"Branches-only mutation guard" in msg
|
||||
or "stable control checkout" in msg,
|
||||
msg,
|
||||
)
|
||||
self.assertIn("control checkout", msg)
|
||||
|
||||
def test_workflow_scope_skips_missing_worktree_for_create_issue_clean_root(self):
|
||||
"""#683 root assessor must not block clean-root create_issue bootstrap."""
|
||||
res = wsg.assess_root_source_mutation(
|
||||
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
porcelain_status="",
|
||||
role_kind="author",
|
||||
mutation_task="create_issue",
|
||||
)
|
||||
self.assertFalse(res["block"], res)
|
||||
self.assertTrue(res.get("create_issue_bootstrap") or res["proven"])
|
||||
|
||||
def test_workflow_scope_still_blocks_clean_root_for_lock_issue(self):
|
||||
res = wsg.assess_root_source_mutation(
|
||||
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
porcelain_status="",
|
||||
role_kind="author",
|
||||
mutation_task="lock_issue",
|
||||
)
|
||||
self.assertTrue(res["block"])
|
||||
self.assertEqual(res["blocker_kind"], wsg.BLOCKER_MISSING_WORKTREE)
|
||||
|
||||
def test_arbitrary_branches_directory_not_bootstrap(self):
|
||||
"""#713: mkdir fake under branches/ is not the bootstrap path."""
|
||||
fake = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "fake-mkdir-only")
|
||||
res = cib.assess_create_issue_bootstrap(
|
||||
workspace_path=fake,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
current_branch="master",
|
||||
head_sha=MASTER_SHA,
|
||||
porcelain_status="",
|
||||
remote_master_sha=MASTER_SHA,
|
||||
task="create_issue",
|
||||
)
|
||||
# Under branches/ → not bootstrap; ordinary membership/registration applies.
|
||||
self.assertTrue(res["not_applicable"])
|
||||
self.assertFalse(res["allowed"])
|
||||
|
||||
|
||||
class TestCreateIssueCapabilityAgreement(unittest.TestCase):
|
||||
def test_map_and_alias_agree_on_create_issue(self):
|
||||
import task_capability_map as tcm
|
||||
|
||||
self.assertEqual(
|
||||
tcm.required_permission("create_issue"),
|
||||
"gitea.issue.create",
|
||||
)
|
||||
# Tool alias must resolve to the same task contract.
|
||||
alias = getattr(tcm, "TOOL_TASK_ALIASES", None) or getattr(
|
||||
tcm, "TASK_ALIASES", None
|
||||
)
|
||||
if alias is not None:
|
||||
mapped = alias.get("gitea_create_issue")
|
||||
if mapped is not None:
|
||||
self.assertEqual(mapped, "create_issue")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,15 +26,23 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = "create_issue"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
|
||||
# Disable early return in verify_preflight_purity for testing
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
# #618: isolate from ambient session issue locks
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
self._lock_patch.stop()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@@ -51,29 +59,62 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
def test_create_issue_stable_checkout_rejected(
|
||||
def test_create_issue_stable_checkout_bootstrap_allowed_when_clean(
|
||||
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||
):
|
||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
||||
# #749: clean canonical control checkout is the sanctioned create_issue path.
|
||||
mock_api.return_value = {
|
||||
"number": 77,
|
||||
"html_url": "https://gitea.example.com/issues/77",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
try:
|
||||
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
||||
except RuntimeError as exc:
|
||||
self.assertIn("stable control checkout", str(exc))
|
||||
else:
|
||||
# #683: production guards return typed blockers at entrypoints
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertFalse(res.get("performed"))
|
||||
blob = " ".join(res.get("reasons") or []) + " " + str(
|
||||
res.get("blocker_kind") or ""
|
||||
)
|
||||
self.assertTrue(
|
||||
"stable control checkout" in blob
|
||||
or "missing_issue_worktree" in blob
|
||||
or "control checkout" in blob.lower()
|
||||
)
|
||||
self.assertTrue(res.get("exact_next_action"))
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch.object(srv, "_run_anti_stomp_preflight", return_value=None):
|
||||
with patch.object(srv, "_enforce_root_checkout_guard"):
|
||||
res = srv.gitea_create_issue(
|
||||
title="Test issue", body="body text for gate"
|
||||
)
|
||||
self.assertEqual(res.get("number"), 77)
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": " M dirty.py\n",
|
||||
},
|
||||
)
|
||||
def test_create_issue_dirty_control_checkout_rejected(
|
||||
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||
):
|
||||
# #749: dirty control checkout still fails closed (no bootstrap).
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server._get_workspace_porcelain",
|
||||
return_value=" M dirty.py\n",
|
||||
):
|
||||
try:
|
||||
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
||||
except RuntimeError as exc:
|
||||
self.assertTrue(
|
||||
"tracked local edits" in str(exc)
|
||||
or "dirty" in str(exc).lower()
|
||||
or "bootstrap" in str(exc).lower()
|
||||
or "control checkout" in str(exc).lower()
|
||||
)
|
||||
else:
|
||||
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||
blob = " ".join(res.get("reasons") or [])
|
||||
self.assertTrue(blob or res.get("blocker_kind"))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
|
||||
+23
-2
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Tests for create_pr.py.
|
||||
|
||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||
@@ -8,7 +9,7 @@ import json
|
||||
import sys
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
import create_pr # noqa: E402
|
||||
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# API payload
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
|
||||
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_payload_fields(self, _cred):
|
||||
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = MockReq.call_args[0][0]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
"""Complete canonical-root consumption for cross-repository MCP namespaces.
|
||||
|
||||
#706 introduced an immutable, configured ``canonical_repository_root`` and
|
||||
routed the #274 filesystem guards and the session repository *slug* through it.
|
||||
Three consumption paths were left behind, and this module drives each one
|
||||
through its production entry point:
|
||||
|
||||
* **A — remote initialization.** ``gitea_get_runtime_context`` never normalized
|
||||
its ``remote`` argument, while ``gitea_whoami`` did. A fresh process whose
|
||||
first native call is the runtime-context path therefore pinned the
|
||||
``dadeschools`` argument default instead of the profile's configured remote,
|
||||
and — because first-bind is first-write-wins — no later correct call could
|
||||
repair it.
|
||||
|
||||
* **C — reconciler branch deletion.** The delete-branch repository-binding
|
||||
guard derived its expected slug from ``_workspace_repository_slug``, which
|
||||
reads the git remote of the *installation* checkout (always Gitea-Tools),
|
||||
rather than from the session's configured canonical root.
|
||||
|
||||
* **D — parity evidence.** ``gitea_assess_master_parity`` proves Gitea-Tools
|
||||
*server implementation* parity only, which is intentional. It carried no
|
||||
target-repository dimension at all, so a cross-repository namespace had no
|
||||
evidence that its target checkout was current. The existing
|
||||
``startup_head``/``current_head`` semantics are preserved unchanged and the
|
||||
target-repository assessment is reported under separately labelled fields.
|
||||
|
||||
Groups B and E assert *intentional* behaviour (the #274 guards already consume
|
||||
the canonical root; the configuration surface already validates a
|
||||
repository-specific namespace) so that a regression in either is caught.
|
||||
|
||||
Real git repositories are used throughout; no network calls are made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
INSTALL_ORG = "Scaled-Tech-Consulting"
|
||||
INSTALL_REPO = "Gitea-Tools"
|
||||
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
|
||||
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
|
||||
|
||||
TARGET_ORG = "Scaled-Tech-Consulting"
|
||||
TARGET_REPO = "mcp-control-plane"
|
||||
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
|
||||
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
res = subprocess.run(
|
||||
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||
)
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(str(path), "init", "-q")
|
||||
_git(str(path), "config", "user.email", "[email protected]")
|
||||
_git(str(path), "config", "user.name", "Test")
|
||||
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||
(path / "README.md").write_text("seed\n")
|
||||
_git(str(path), "add", "README.md")
|
||||
_git(str(path), "commit", "-q", "-m", "seed")
|
||||
return os.path.realpath(str(path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared profile/config construction
|
||||
# ---------------------------------------------------------------------------
|
||||
_AUTHOR_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
_AUTHOR_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
# A profile that may approve or merge must forbid authoring (gitea_config's
|
||||
# reviewer-identity deadlock rule).
|
||||
_REVIEWER_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
_REVIEWER_FORBIDDEN = ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"]
|
||||
_MERGER_OPS = ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"]
|
||||
_MERGER_FORBIDDEN = [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
_RECONCILER_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
]
|
||||
_RECONCILER_FORBIDDEN = [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
|
||||
_ROLE_MATRIX = {
|
||||
"author": (_AUTHOR_OPS, _AUTHOR_FORBIDDEN),
|
||||
"reviewer": (_REVIEWER_OPS, _REVIEWER_FORBIDDEN),
|
||||
"merger": (_MERGER_OPS, _MERGER_FORBIDDEN),
|
||||
"reconciler": (_RECONCILER_OPS, _RECONCILER_FORBIDDEN),
|
||||
}
|
||||
|
||||
|
||||
def _profile(
|
||||
role: str,
|
||||
*,
|
||||
canonical_root: str | None = None,
|
||||
allowed_repositories: list[str] | None = None,
|
||||
username: str = "jcwalker3",
|
||||
) -> dict:
|
||||
allowed, forbidden = _ROLE_MATRIX[role]
|
||||
profile = {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": role,
|
||||
"username": username,
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
|
||||
"allowed_operations": list(allowed),
|
||||
"forbidden_operations": list(forbidden),
|
||||
"execution_profile": f"prgs-{role}",
|
||||
}
|
||||
if canonical_root is not None:
|
||||
profile["canonical_repository_root"] = canonical_root
|
||||
if allowed_repositories is not None:
|
||||
profile["allowed_repositories"] = list(allowed_repositories)
|
||||
return profile
|
||||
|
||||
|
||||
def _config(profiles: dict) -> dict:
|
||||
return {
|
||||
"version": 2,
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {
|
||||
"enabled": True,
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
|
||||
class _ServerHarness(unittest.TestCase):
|
||||
"""Temp profiles.json + pinned install remote + no network."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
self.config_path = os.path.join(self.tmp, "profiles.json")
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
# The install checkout's remote is Gitea-Tools regardless of the
|
||||
# developer's layout; pin it so "install-derived" is deterministic.
|
||||
self._remote_url = patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=self._install_remote_url
|
||||
)
|
||||
self._remote_url.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._remote_url.stop()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _install_remote_url(self, remote_name):
|
||||
return INSTALL_URL if remote_name in ("prgs", "origin") else None
|
||||
|
||||
def _write_config(self, profiles: dict) -> None:
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(_config(profiles)))
|
||||
|
||||
def _env(self, profile_name: str, **extra) -> dict:
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "t",
|
||||
"GITEA_TOKEN_PRGS_REVIEWER": "t",
|
||||
"GITEA_TOKEN_PRGS_MERGER": "t",
|
||||
"GITEA_TOKEN_PRGS_RECONCILER": "t",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def _api(self, method, url, header):
|
||||
return {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "Test",
|
||||
"id": 1,
|
||||
"email": "[email protected]",
|
||||
}
|
||||
|
||||
def _live(self):
|
||||
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# A. Remote initialization
|
||||
# ===========================================================================
|
||||
class TestRemoteInitializationFirstCall(_ServerHarness):
|
||||
"""A prgs namespace must never pin the dadeschools argument default."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._write_config(
|
||||
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||
)
|
||||
|
||||
def test_runtime_context_first_call_does_not_bind_default_remote(self):
|
||||
"""Runtime-context as the FIRST native call, relying on the default."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
srv.gitea_get_runtime_context()
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx, "first call must establish a session binding")
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
self.assertEqual(ctx["org"], INSTALL_ORG)
|
||||
self.assertEqual(ctx["repository"], INSTALL_REPO)
|
||||
|
||||
def test_runtime_context_first_call_reports_effective_remote(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context()
|
||||
self.assertEqual(result["remote"], "prgs")
|
||||
|
||||
def test_whoami_first_call_remains_correct(self):
|
||||
"""Control: the already-normalizing path is unchanged."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
srv.gitea_whoami()
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
|
||||
def test_explicit_remote_argument_still_honoured(self):
|
||||
"""Normalization only fills the default; explicit values are untouched."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context(remote="prgs")
|
||||
self.assertEqual(result["remote"], "prgs")
|
||||
|
||||
def test_mdcps_profile_default_is_not_rewritten_to_prgs(self):
|
||||
"""A dadeschools-hosted profile keeps the dadeschools remote."""
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
profile["context"] = "mdcps"
|
||||
profile["base_url"] = "https://gitea.dadeschools.net"
|
||||
self._write_config({"prgs-author": profile})
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context()
|
||||
self.assertEqual(result["remote"], "dadeschools")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# B. Remote/repository guard (intentional behaviour — regression fence)
|
||||
# ===========================================================================
|
||||
class TestCanonicalRootGuardBinding(_ServerHarness):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self.install_root = _init_repo(Path(self.tmp) / "install", INSTALL_URL)
|
||||
|
||||
def test_canonical_target_root_resolves_to_target_slug(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.target_root,
|
||||
source="profile.canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install_root,
|
||||
remote="prgs",
|
||||
require_binding=True,
|
||||
)
|
||||
self.assertFalse(got.get("block"), got.get("reasons"))
|
||||
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
||||
|
||||
def test_install_root_identity_remains_gitea_tools(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=None,
|
||||
source=None,
|
||||
expected_slug=None,
|
||||
process_project_root=self.install_root,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertEqual(
|
||||
os.path.realpath(got["canonical_repo_root"]), self.install_root
|
||||
)
|
||||
|
||||
def test_guards_validate_against_canonical_target_root(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=None,
|
||||
process_project_root=self.install_root,
|
||||
configured_canonical_root=self.target_root,
|
||||
)
|
||||
self.assertEqual(
|
||||
os.path.realpath(ctx["canonical_repo_root"]), self.target_root
|
||||
)
|
||||
|
||||
def test_explicit_coordinates_cannot_override_canonical_binding(self):
|
||||
"""Explicit org/repo may confirm, never authorize, a binding."""
|
||||
confirm = session_ctx.assess_repository_override(
|
||||
requested_org=TARGET_ORG,
|
||||
requested_repo=TARGET_REPO,
|
||||
bound_org=TARGET_ORG,
|
||||
bound_repo=TARGET_REPO,
|
||||
)
|
||||
self.assertFalse(confirm.get("block"))
|
||||
override = session_ctx.assess_repository_override(
|
||||
requested_org=TARGET_ORG,
|
||||
requested_repo=TARGET_REPO,
|
||||
bound_org=INSTALL_ORG,
|
||||
bound_repo=INSTALL_REPO,
|
||||
)
|
||||
self.assertTrue(override.get("block"))
|
||||
|
||||
def test_gitea_tools_rooted_namespace_cannot_reach_target_repository(self):
|
||||
"""No canonical root configured → session stays pinned to Gitea-Tools."""
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
resolved = srv._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=True
|
||||
)
|
||||
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||
self.assertNotEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
def test_target_rooted_namespace_binds_target_repository(self):
|
||||
profile = _profile(
|
||||
"author",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# C. Reconciler branch deletion
|
||||
# ===========================================================================
|
||||
class TestDeleteBranchRepositoryBinding(_ServerHarness):
|
||||
"""The binding guard must consult the canonical root, not PROJECT_ROOT.
|
||||
|
||||
No branch is ever deleted here: only the pre-deletion binding guard is
|
||||
exercised.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self._write_config(
|
||||
{
|
||||
"prgs-reconciler": _profile(
|
||||
"reconciler",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
),
|
||||
"gt-reconciler": _profile(
|
||||
"reconciler", allowed_repositories=[INSTALL_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def test_target_rooted_reconciler_validates_target_deletion(self):
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNone(block, block)
|
||||
|
||||
def test_target_rooted_reconciler_fails_closed_for_install_repository(self):
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
self.assertFalse(block["performed"])
|
||||
|
||||
def test_install_rooted_reconciler_fails_closed_for_target_repository(self):
|
||||
with patch.dict(os.environ, self._env("gt-reconciler"), clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
|
||||
def test_env_configured_canonical_root_is_honoured(self):
|
||||
env = self._env("gt-reconciler")
|
||||
env[crr.CANONICAL_ROOT_ENV] = self.target_root
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
allowed = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
blocked = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||
)
|
||||
self.assertIsNone(allowed, allowed)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_unresolvable_canonical_root_fails_closed(self):
|
||||
env = self._env("gt-reconciler")
|
||||
env[crr.CANONICAL_ROOT_ENV] = os.path.join(self.tmp, "does-not-exist")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
|
||||
def test_no_request_parameter_can_override_canonical_identity(self):
|
||||
"""Every explicit coordinate that is not the canonical target is
|
||||
refused; none of them can *establish* the binding."""
|
||||
# Both repositories share an org, so a wrong *org* case must name a
|
||||
# genuinely different owner to be meaningful.
|
||||
cases = [
|
||||
(INSTALL_ORG, INSTALL_REPO),
|
||||
(TARGET_ORG, INSTALL_REPO),
|
||||
(TARGET_ORG, "some-other-repo"),
|
||||
("someone-else", TARGET_REPO),
|
||||
]
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
for org, repo in cases:
|
||||
with self.subTest(org=org, repo=repo):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=org, repo=repo
|
||||
)
|
||||
self.assertIsNotNone(block, f"{org}/{repo} must fail closed")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# D. Parity semantics
|
||||
# ===========================================================================
|
||||
class TestTargetRepositoryParityAssessment(unittest.TestCase):
|
||||
"""Server-implementation parity is preserved; target parity is additive."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
|
||||
def tearDown(self):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _target(self) -> str:
|
||||
return _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
|
||||
def test_unconfigured_target_is_reported_not_stale(self):
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=None, source=None
|
||||
)
|
||||
self.assertFalse(got["configured"])
|
||||
self.assertFalse(got["stale"])
|
||||
self.assertIsNone(got["canonical_repository_root"])
|
||||
|
||||
def test_configured_target_reports_checkout_head_and_slug(self):
|
||||
root = self._target()
|
||||
head = _git(root, "rev-parse", "HEAD")
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=root, source="profile.canonical_repository_root"
|
||||
)
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertEqual(got["canonical_repository_root"], root)
|
||||
self.assertEqual(got["checkout_head"], head)
|
||||
self.assertEqual(got["repository_slug"], TARGET_SLUG)
|
||||
|
||||
def test_target_stale_when_remote_tracking_ref_is_ahead(self):
|
||||
root = self._target()
|
||||
head = _git(root, "rev-parse", "HEAD")
|
||||
# Simulate a fetched remote-tracking ref that has advanced.
|
||||
_git(root, "checkout", "-q", "-b", "advanced")
|
||||
(Path(root) / "next.md").write_text("next\n")
|
||||
_git(root, "add", "next.md")
|
||||
_git(root, "commit", "-q", "-m", "advance")
|
||||
advanced = _git(root, "rev-parse", "HEAD")
|
||||
_git(root, "update-ref", "refs/remotes/origin/master", advanced)
|
||||
_git(root, "checkout", "-q", "--detach", head)
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=root, source="profile.canonical_repository_root"
|
||||
)
|
||||
self.assertEqual(got["checkout_head"], head)
|
||||
self.assertEqual(got["remote_tracking_head"], advanced)
|
||||
self.assertTrue(got["stale"])
|
||||
|
||||
def test_missing_root_is_not_determinable_and_fails_closed(self):
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=os.path.join(self.tmp, "absent"),
|
||||
source="profile.canonical_repository_root",
|
||||
)
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertFalse(got["determinable"])
|
||||
self.assertTrue(got["reasons"])
|
||||
|
||||
|
||||
class TestParityToolEvidenceDimensions(_ServerHarness):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self._write_config(
|
||||
{
|
||||
"prgs-author": _profile(
|
||||
"author",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def test_existing_server_parity_semantics_are_unchanged(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
# startup_head/current_head keep their Gitea-Tools implementation
|
||||
# meaning and must not be redefined to the target repository.
|
||||
self.assertEqual(result["process_root"], srv.PROJECT_ROOT)
|
||||
self.assertEqual(
|
||||
result["startup_head"], srv._STARTUP_PARITY.get("startup_head")
|
||||
)
|
||||
self.assertIn("in_parity", result)
|
||||
|
||||
def test_evidence_distinguishes_server_and_target_dimensions(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
server = result["server_implementation"]
|
||||
self.assertEqual(server["installation_root"], srv.PROJECT_ROOT)
|
||||
self.assertEqual(server["current_head"], result["current_head"])
|
||||
self.assertIn("stale", server)
|
||||
|
||||
target = result["target_repository"]
|
||||
self.assertTrue(target["configured"])
|
||||
self.assertEqual(target["canonical_repository_root"], self.target_root)
|
||||
self.assertEqual(target["repository_slug"], TARGET_SLUG)
|
||||
self.assertEqual(
|
||||
target["checkout_head"], _git(self.target_root, "rev-parse", "HEAD")
|
||||
)
|
||||
self.assertIn("stale", target)
|
||||
|
||||
def test_unconfigured_namespace_reports_target_as_unconfigured(self):
|
||||
self._write_config(
|
||||
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||
)
|
||||
env = self._env("prgs-author")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertFalse(result["target_repository"]["configured"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E. Startup / configuration validation for a candidate namespace set
|
||||
# ===========================================================================
|
||||
class TestCandidateNamespaceConfiguration(_ServerHarness):
|
||||
"""Four repository-specific profiles for the target repository."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self.profiles = {
|
||||
f"mcpcp-{role}": _profile(
|
||||
role,
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
for role in ("author", "reviewer", "merger", "reconciler")
|
||||
}
|
||||
self._write_config(self.profiles)
|
||||
|
||||
def test_configuration_audit_succeeds(self):
|
||||
with patch.dict(os.environ, self._env("mcpcp-author"), clear=False):
|
||||
audit = srv.gitea_audit_config()
|
||||
self.assertTrue(audit.get("configured"))
|
||||
names = {row["name"] for row in audit["profiles"]}
|
||||
self.assertEqual(names, set(self.profiles))
|
||||
|
||||
def test_no_inline_credentials_are_present(self):
|
||||
raw = json.loads(Path(self.config_path).read_text())
|
||||
for name, profile in raw["profiles"].items():
|
||||
with self.subTest(profile=name):
|
||||
self.assertNotIn("token", profile)
|
||||
self.assertNotIn("password", profile)
|
||||
self.assertNotIn("secret", profile)
|
||||
self.assertIn(profile["auth"]["type"], ("env", "keychain"))
|
||||
|
||||
def test_bind_time_validation_succeeds_for_target_repository(self):
|
||||
for name, profile in self.profiles.items():
|
||||
with self.subTest(profile=name):
|
||||
resolved = srv._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=True
|
||||
)
|
||||
self.assertEqual(resolved["reasons"], [])
|
||||
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
def test_wrong_repository_root_fails_closed(self):
|
||||
wrong_root = _init_repo(Path(self.tmp) / "wrong", INSTALL_URL)
|
||||
profile = _profile(
|
||||
"author", canonical_root=wrong_root, allowed_repositories=[TARGET_SLUG]
|
||||
)
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertIsNone(resolved["repository"])
|
||||
self.assertTrue(resolved["reasons"])
|
||||
|
||||
def test_existing_install_profiles_are_unchanged_in_behaviour(self):
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertEqual(resolved["org"], INSTALL_ORG)
|
||||
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||
|
||||
def _denied(self, profile: dict, operation: str) -> bool:
|
||||
ok, _ = gitea_config.check_operation(
|
||||
operation,
|
||||
profile["allowed_operations"],
|
||||
profile["forbidden_operations"],
|
||||
)
|
||||
return not ok
|
||||
|
||||
def test_role_separation_is_enforced(self):
|
||||
expectations = {
|
||||
"author": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"reviewer": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||
"merger": [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"reconciler": [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
}
|
||||
for role, denied_ops in expectations.items():
|
||||
profile = self.profiles[f"mcpcp-{role}"]
|
||||
for op in denied_ops:
|
||||
with self.subTest(role=role, operation=op):
|
||||
self.assertTrue(
|
||||
self._denied(profile, op),
|
||||
f"{role} must not be permitted {op}",
|
||||
)
|
||||
|
||||
def test_each_role_retains_its_own_capability(self):
|
||||
permitted = {
|
||||
"author": "gitea.pr.create",
|
||||
"reviewer": "gitea.pr.approve",
|
||||
"merger": "gitea.pr.merge",
|
||||
"reconciler": "gitea.branch.delete",
|
||||
}
|
||||
for role, op in permitted.items():
|
||||
with self.subTest(role=role, operation=op):
|
||||
self.assertFalse(self._denied(self.profiles[f"mcpcp-{role}"], op))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||
"""Tests for gitea_delete_branch capability + role gate (Issue #408, #729).
|
||||
|
||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||
structured permission report). With it, deletion proceeds through existing
|
||||
preflight and audit unchanged.
|
||||
structured permission report).
|
||||
|
||||
#729: delete_branch is reconciler-owned. ``gitea.branch.delete`` is granted only
|
||||
to the reconciler profile, so the resolver classifies delete_branch as a
|
||||
reconciler task. Raw ``gitea_delete_branch`` still redirects the reconciler to
|
||||
the guarded ``gitea_cleanup_merged_pr_branch`` path (#514/#687); author,
|
||||
reviewer, and merger remain denied by the permission gate and/or the required
|
||||
role gate.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -16,6 +22,8 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
import task_capability_map
|
||||
import role_session_router
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
@@ -38,6 +46,22 @@ AUTHOR_WITH_DELETE = {
|
||||
"audit_label": "prgs-author-deleter",
|
||||
}
|
||||
|
||||
# #729: delete_branch is reconciler-owned. The reconciler holds
|
||||
# gitea.branch.delete but raw gitea_delete_branch redirects it to the guarded
|
||||
# gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||
RECONCILER_WITH_DELETE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
@@ -81,6 +105,20 @@ CONFIG = {
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
},
|
||||
# #729: reconciler is the only delete-capable role.
|
||||
"reconciler-profile": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reconciler",
|
||||
"username": "reconciler-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_RECONCILER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||
"execution_profile": "reconciler-profile",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
@@ -121,6 +159,9 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def _delete_calls(self):
|
||||
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||
|
||||
def test_blocked_without_delete_capability(self):
|
||||
self._set_profile(AUTHOR_NO_DELETE)
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
@@ -135,33 +176,53 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
self.mock_api.assert_not_called()
|
||||
self.mock_auth.assert_not_called()
|
||||
|
||||
def test_allowed_delete_proceeds(self):
|
||||
def test_author_with_delete_perm_denied_by_role_gate(self):
|
||||
"""#729: even an author holding gitea.branch.delete is denied — the
|
||||
required role is now reconciler. Fail closed, no API DELETE."""
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertEqual(res["active_role_kind"], "author")
|
||||
self.assertTrue(res["reasons"])
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_reviewer_with_delete_perm_denied_by_role_gate(self):
|
||||
"""A reviewer that somehow holds the permission is still denied."""
|
||||
reviewer_with_delete = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.review", "gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-reviewer",
|
||||
}
|
||||
self._set_profile(reviewer_with_delete)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertEqual(res["active_role_kind"], "reviewer")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_reconciler_performs_raw_delete(self):
|
||||
"""#729: the reconciler is the delete-capable role and performs the raw
|
||||
deletion (no audit phase active here); the API DELETE is issued."""
|
||||
self._set_profile(RECONCILER_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("deleted", res["message"])
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_allowed_delete_audited_with_capability_proof(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
mock_write = patch("gitea_audit.write_event").start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
mock_write.assert_called()
|
||||
event = mock_write.call_args[0][0]
|
||||
self.assertEqual(event["action"], "delete_branch")
|
||||
self.assertEqual(
|
||||
event["request_metadata"]["required_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.assertTrue(self._delete_calls())
|
||||
|
||||
|
||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
@@ -194,24 +255,89 @@ class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
||||
}
|
||||
|
||||
def _delete_calls(self):
|
||||
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||
|
||||
def test_reconciler_resolver_allows_delete_branch(self):
|
||||
"""#729: resolve(delete_branch) on the reconciler profile is allowed and
|
||||
classified as a reconciler task."""
|
||||
with patch.dict(os.environ, self._env("reconciler-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
# Deterministic role-map outcomes (avoid runtime-staleness-dependent
|
||||
# allowed_in_current_session, which folds in reconnect state).
|
||||
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||
self.assertEqual(
|
||||
resolve["required_operation_permission"], "gitea.branch.delete")
|
||||
self.assertTrue(resolve["active_profile_permission_allowed"])
|
||||
self.assertTrue(resolve["configured"])
|
||||
self.assertIn("reconciler-profile", resolve["matching_configured_profile"])
|
||||
self.assertEqual(resolve["active_role_kind"], "reconciler")
|
||||
|
||||
def test_author_resolver_denies_delete_branch(self):
|
||||
"""#729: an author session cannot resolve delete_branch — required role
|
||||
is reconciler and the author lacks the permission."""
|
||||
with patch.dict(os.environ, self._env("author-no-delete"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
|
||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||
"forbidden_operations": ["gitea.branch.delete"],
|
||||
}).start()
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
resolve["required_operation_permission"],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
|
||||
class TestDeleteBranchRoleMapParity(unittest.TestCase):
|
||||
"""#729: both single-source-of-truth maps must classify delete_branch as
|
||||
reconciler and stay in agreement."""
|
||||
|
||||
def test_task_capability_map_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"), "reconciler")
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission("delete_branch"),
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
def test_router_required_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||
"reconciler",
|
||||
)
|
||||
self.assertEqual(
|
||||
role_session_router.required_role_for_task("delete_branch"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_router_set_membership_moved(self):
|
||||
self.assertIn("delete_branch", role_session_router.RECONCILER_TASKS)
|
||||
self.assertNotIn("delete_branch", role_session_router.AUTHOR_TASKS)
|
||||
|
||||
def test_maps_agree(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"),
|
||||
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||
mutations = list(mutations or [])
|
||||
corr_pr = None
|
||||
if correction and mutations:
|
||||
corr_pr = mutations[-1].get("pr_number")
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
|
||||
"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 []),
|
||||
"live_mutations": mutations,
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": None,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": corr_pr,
|
||||
"correction_head_sha": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase):
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
|
||||
def test_rc_head_a_blocks_other_pr(self):
|
||||
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
|
||||
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
|
||||
_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)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_legacy_619_ledger_allows_new_head(self):
|
||||
"""PR #619-style durable lock without mutation head_sha."""
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Regression tests for durable author worktree resolution (#618)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
|
||||
|
||||
class TestDurableAuthorWorktreeResolution(unittest.TestCase):
|
||||
def test_missing_author_env_fails_closed_no_control_fallback(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
author_worktree_env=missing,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["bound_worktree_missing"])
|
||||
self.assertFalse(result["silent_control_fallback"])
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, result["reasons"][0])
|
||||
self.assertNotEqual(
|
||||
os.path.realpath(result["workspace_path"]),
|
||||
os.path.realpath(CONTROL_CHECKOUT_ROOT),
|
||||
)
|
||||
|
||||
def test_missing_active_env_fails_closed(self):
|
||||
missing = "/nonexistent/branches/deleted-active"
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
active_worktree_env=missing,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["bound_worktree_missing"])
|
||||
self.assertIn(amw.ACTIVE_WORKTREE_ENV, result["workspace_binding_source"])
|
||||
|
||||
def test_derives_from_active_author_issue_lock(self):
|
||||
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
session_lock_worktree=lock_wt,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(os.path.abspath(lock_wt))
|
||||
)
|
||||
self.assertIn("issue lock", result["workspace_binding_source"])
|
||||
|
||||
def test_explicit_worktree_path_wins_over_lock(self):
|
||||
explicit = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-explicit")
|
||||
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
worktree_path=explicit,
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
session_lock_worktree=lock_wt,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(os.path.abspath(explicit))
|
||||
)
|
||||
self.assertEqual(result["workspace_binding_source"], "worktree_path argument")
|
||||
|
||||
def test_no_binding_does_not_silently_use_control_checkout(self):
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["silent_control_fallback"])
|
||||
blob = " ".join(result["reasons"])
|
||||
self.assertIn("control checkout", blob)
|
||||
self.assertIn("forbidden", blob)
|
||||
|
||||
def test_process_root_under_branches_is_allowed(self):
|
||||
branches_root = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "session-wt")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=branches_root,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(branches_root)
|
||||
)
|
||||
self.assertIn("branches/", result["workspace_binding_source"])
|
||||
|
||||
def test_lock_ownership_mismatch_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = tmp
|
||||
branches = os.path.join(root, "branches")
|
||||
os.makedirs(os.path.join(branches, "a"))
|
||||
os.makedirs(os.path.join(branches, "b"))
|
||||
# Seed a fake .git so membership/list may soft-fail without hard error
|
||||
os.makedirs(os.path.join(root, ".git"))
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
worktree_path=os.path.join(branches, "a"),
|
||||
process_project_root=root,
|
||||
session_lock_worktree=os.path.join(branches, "b"),
|
||||
canonical_repo_root=root,
|
||||
validate=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("lock" in r.lower() and "match" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_traversal_safety_blocks_escape(self):
|
||||
assessment = amw.assess_path_traversal_safety(
|
||||
path="/tmp/other-repo/branches/evil",
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
self.assertTrue(any("escapes" in r for r in assessment["reasons"]))
|
||||
|
||||
def test_bound_worktree_existence_reports_null_git_root(self):
|
||||
assessment = amw.assess_bound_worktree_existence(
|
||||
configured_path="/nonexistent/branches/gone",
|
||||
binding_source=f"{amw.AUTHOR_WORKTREE_ENV} environment variable",
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
profile_name="prgs-author",
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
self.assertIsNone(assessment["inspected_git_root"])
|
||||
self.assertFalse(assessment["path_exists"])
|
||||
msg = amw.format_bound_worktree_missing_error(assessment)
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, msg)
|
||||
self.assertIn("prgs-author", msg)
|
||||
self.assertIn("recreate or repoint", msg.lower())
|
||||
|
||||
|
||||
class TestNamespaceAuthorNoDemotion(unittest.TestCase):
|
||||
def test_author_missing_env_not_demoted_to_process_root(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
demotions: list[str] = []
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="author",
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={amw.AUTHOR_WORKTREE_ENV: missing},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertIn("AUTHOR", source)
|
||||
self.assertNotEqual(os.path.realpath(path), os.path.realpath(CONTROL_CHECKOUT_ROOT))
|
||||
self.assertTrue(any("not demoted" in d for d in demotions))
|
||||
|
||||
def test_reviewer_still_demotes_missing_env(self):
|
||||
"""#702 demotion retained for non-author roles."""
|
||||
demotions: list[str] = []
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-gone"},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(path, os.path.realpath(CONTROL_CHECKOUT_ROOT))
|
||||
self.assertTrue(demotions)
|
||||
|
||||
def test_mutation_context_surfaces_missing_binding_health(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=None,
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={amw.AUTHOR_WORKTREE_ENV: "/nonexistent/branches/mcp-author-clean-ns"},
|
||||
profile_name="prgs-author",
|
||||
)
|
||||
self.assertTrue(ctx.get("bound_worktree_missing"))
|
||||
self.assertTrue(ctx.get("author_worktree_block"))
|
||||
self.assertIsNone(ctx.get("inspected_git_root"))
|
||||
self.assertFalse(ctx.get("path_exists"))
|
||||
|
||||
|
||||
class TestCreateIssueAndCommentAgreeOnMissingWorktree(unittest.TestCase):
|
||||
"""AC3/AC4: create_issue and create_issue_comment enforce the same rule."""
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = None
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
self.addCleanup(self._restore)
|
||||
|
||||
def _restore(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
self._lock_patch.stop()
|
||||
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
|
||||
os.environ.pop(amw.ACTIVE_WORKTREE_ENV, None)
|
||||
|
||||
def _assert_blocked_missing(self, result_or_exc):
|
||||
if isinstance(result_or_exc, BaseException):
|
||||
blob = str(result_or_exc)
|
||||
else:
|
||||
blob = " ".join(
|
||||
str(x)
|
||||
for x in (
|
||||
result_or_exc.get("reasons") or [],
|
||||
result_or_exc.get("message"),
|
||||
result_or_exc.get("blocker_kind"),
|
||||
)
|
||||
if x
|
||||
)
|
||||
if not blob:
|
||||
blob = str(result_or_exc)
|
||||
self.assertTrue(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in blob
|
||||
or "does not exist" in blob
|
||||
or "bound worktree" in blob.lower(),
|
||||
msg=blob,
|
||||
)
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
def test_create_issue_blocked_when_author_env_missing(
|
||||
self, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||
):
|
||||
missing = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
|
||||
)
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
srv._preflight_resolved_task = "create_issue"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
try:
|
||||
res = srv.gitea_create_issue(title="Test issue", body="body text here")
|
||||
except RuntimeError as exc:
|
||||
self._assert_blocked_missing(exc)
|
||||
else:
|
||||
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||
self._assert_blocked_missing(res)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_create_issue_comment_blocked_when_author_env_missing(self, mock_api, _auth):
|
||||
missing = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
|
||||
)
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
srv._preflight_resolved_task = "comment_issue"
|
||||
author_env = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
amw.AUTHOR_WORKTREE_ENV: missing,
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, author_env, clear=False):
|
||||
try:
|
||||
res = srv.gitea_create_issue_comment(
|
||||
issue_number=618,
|
||||
body="evidence comment",
|
||||
remote="prgs",
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
self._assert_blocked_missing(exc)
|
||||
else:
|
||||
self.assertFalse(res.get("success", True))
|
||||
self._assert_blocked_missing(res)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
class TestRuntimeContextUnhealthyMissingWorktree(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._lock_patch.stop()
|
||||
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
|
||||
|
||||
def test_assess_preflight_reports_null_git_root_and_missing(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.pr.create"],
|
||||
"forbidden_operations": [],
|
||||
}):
|
||||
status = srv.assess_preflight_status()
|
||||
self.assertFalse(status["preflight_ready"])
|
||||
blob = " ".join(status["preflight_block_reasons"])
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, blob)
|
||||
details = status["preflight_workspace"]
|
||||
self.assertIsNotNone(details)
|
||||
self.assertTrue(details.get("bound_worktree_missing"))
|
||||
self.assertIsNone(details.get("inspected_git_root"))
|
||||
self.assertFalse(details.get("path_exists"))
|
||||
self.assertFalse(details.get("workspace_healthy"))
|
||||
|
||||
|
||||
class TestThreadLedgerExample(unittest.TestCase):
|
||||
def test_bound_worktree_missing_ledger_example_exists(self):
|
||||
import thread_state_ledger_examples as examples
|
||||
|
||||
names = [name for name, _h, _l in examples.EXAMPLES]
|
||||
self.assertIn("bound_worktree_missing_blocker", names)
|
||||
for name, _handoff, ledger in examples.EXAMPLES:
|
||||
if name == "bound_worktree_missing_blocker":
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, ledger)
|
||||
self.assertIn("inspected_git_root", ledger)
|
||||
self.assertIn("operator", ledger.lower())
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Manual MCP daemon-kill contamination guard (#630).
|
||||
|
||||
Covers the four scenarios the acceptance criteria name — manual process kill,
|
||||
sanctioned reconnect, stale-runtime restart, and a contaminated post-restart
|
||||
mutation — across the pure guard, the durable marker, the MCP tools, the
|
||||
pre-flight enforcement gate, and the final-report rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import final_report_validator
|
||||
import mcp_session_state
|
||||
import runtime_recovery_guard as guard
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
|
||||
AUTH_ENV = guard.OPERATOR_AUTHORIZATION_ENV
|
||||
|
||||
|
||||
def _clear_marker(remote="prgs"):
|
||||
srv._clear_runtime_recovery_marker(remote=remote)
|
||||
|
||||
|
||||
def teardown_function():
|
||||
_clear_marker()
|
||||
|
||||
|
||||
def _marker(reason_class=guard.REASON_MANUAL_DAEMON_KILL, **overrides):
|
||||
record = guard.build_contamination_record(
|
||||
reason_class=reason_class,
|
||||
command_redacted="pkill -f mcp_server.py",
|
||||
session_id="prgs-author-1234-abcd",
|
||||
remote="prgs",
|
||||
role="author",
|
||||
detail="manual daemon kill",
|
||||
)
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
# ── AC1/AC2: manual process kill is detected and classified ──────────────────
|
||||
|
||||
def test_pkill_mcp_server_py_is_contamination():
|
||||
result = guard.classify_recovery_command("pkill -f mcp_server.py")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_equivalent_kill_forms_are_contamination():
|
||||
for command in (
|
||||
"pkill -f gitea_mcp_server",
|
||||
"pkill -f mcp",
|
||||
"pkill -9 -f mcp_server.py",
|
||||
"killall mcp_server",
|
||||
"sudo pkill -f mcp_server.py",
|
||||
"killall -9 mcp-server",
|
||||
):
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["contamination"] is True, command
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
|
||||
|
||||
|
||||
def test_broad_pattern_is_collateral_damage_contamination():
|
||||
result = guard.classify_recovery_command("pkill -f python")
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_BROAD_PROCESS_KILL
|
||||
assert "collateral" in " ".join(result["reasons"])
|
||||
|
||||
|
||||
def test_kill_of_known_mcp_pid_is_contamination():
|
||||
result = guard.classify_recovery_command("kill -9 4242", mcp_pids=[4242, 99])
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert "4242" in " ".join(result["reasons"])
|
||||
|
||||
|
||||
def test_kill_resolved_from_mcp_lookup_is_contamination():
|
||||
result = guard.classify_recovery_command("kill $(pgrep -f mcp_server.py)")
|
||||
assert result["contamination"] is True
|
||||
|
||||
|
||||
def test_compound_command_detects_the_kill_half():
|
||||
result = guard.classify_recovery_command(
|
||||
"ps aux | grep mcp_server && pkill -f mcp_server.py"
|
||||
)
|
||||
assert result["contamination"] is True
|
||||
|
||||
|
||||
# ── #787: background separator and subshell forms reach the classifier ───────
|
||||
|
||||
def test_background_separator_kill_is_contamination():
|
||||
result = guard.classify_recovery_command("sleep 1 & pkill -f mcp_server.py")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_subshell_wrapped_kill_is_contamination():
|
||||
result = guard.classify_recovery_command("(pkill -f mcp_server.py)")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_further_background_and_subshell_forms_are_contamination():
|
||||
for command in (
|
||||
"pkill -f mcp_server.py &",
|
||||
"( sudo pkill -f mcp_server.py )",
|
||||
"((pkill -f gitea_mcp_server))",
|
||||
"sleep 1 & killall mcp_server",
|
||||
"(ps aux | grep mcp_server) & pkill -f mcp_server.py",
|
||||
):
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["contamination"] is True, command
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
|
||||
|
||||
|
||||
def test_logical_operators_are_not_split_into_single_characters():
|
||||
# ``&&``/``||`` must still be consumed whole by the separator scan.
|
||||
assert guard._split_segments("a && b || c") == ["a", "b", "c"]
|
||||
assert guard._split_segments("a & b") == ["a", "b"]
|
||||
assert guard._split_segments("(a)") == ["a"]
|
||||
assert guard._split_segments("a; b\nc | d") == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
# ── #789 F1: separators only separate outside quoted or escaped text ─────────
|
||||
|
||||
# The three commands the PR #789 review measured as regressions at head
|
||||
# 6b58f04: each merely *mentions* the canonical kill string inside quotes.
|
||||
F1_QUOTED_COMMANDS = (
|
||||
'git commit -m "block sleep 1 & pkill -f mcp_server.py as recovery"',
|
||||
'echo "docs: sleep 1 & pkill -f mcp_server.py is now detected"',
|
||||
'grep -rn "sleep 1 & pkill -f mcp_server.py" docs/',
|
||||
)
|
||||
|
||||
|
||||
def test_quoted_ampersand_examples_from_review_f1_are_not_kills():
|
||||
for command in F1_QUOTED_COMMANDS:
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False, command
|
||||
assert result["contamination"] is False, command
|
||||
assert result["reason_class"] is None, command
|
||||
|
||||
|
||||
def test_ampersand_inside_double_quotes_is_not_a_separator():
|
||||
assert guard._split_segments('echo "a & b"') == ['echo "a & b"']
|
||||
result = guard.classify_recovery_command(
|
||||
'echo "restart it: sleep 1 & pkill -f mcp_server.py"'
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_ampersand_inside_single_quotes_is_not_a_separator():
|
||||
assert guard._split_segments("echo 'a & b'") == ["echo 'a & b'"]
|
||||
result = guard.classify_recovery_command(
|
||||
"git commit -m 'sleep 1 & pkill -f mcp_server.py stays quoted'"
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_backslash_escaped_ampersand_is_not_a_separator():
|
||||
command = r"echo a \& pkill -f mcp_server.py"
|
||||
assert guard._split_segments(command) == [command]
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_backslash_does_not_escape_inside_single_quotes():
|
||||
# POSIX: a backslash is literal inside single quotes, so the closing quote
|
||||
# still closes and the following ``&`` is a genuinely active separator.
|
||||
command = r"echo 'a\' & pkill -f mcp_server.py"
|
||||
assert guard._split_segments(command) == [r"echo 'a\'", "pkill -f mcp_server.py"]
|
||||
assert guard.classify_recovery_command(command)["contamination"] is True
|
||||
|
||||
|
||||
def test_quote_awareness_also_retires_the_pre_existing_semicolon_and_pipe_cases():
|
||||
# ``;`` and ``|`` misclassified quoted text before #787 as well. The fix is
|
||||
# the quote-unawareness, not the ``&`` instance the issue happens to name.
|
||||
for command in (
|
||||
'git commit -m "fix; pkill -f mcp_server.py"',
|
||||
'git commit -m "fix | pkill -f mcp_server.py"',
|
||||
):
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False, command
|
||||
assert result["contamination"] is False, command
|
||||
|
||||
|
||||
# ── #789 F3: subshell stripping and redirection stay syntactically honest ────
|
||||
|
||||
def test_command_substitution_is_not_mangled_by_subshell_stripping():
|
||||
# Only a wrapper this call opened may be unwrapped; a ``)`` closing ``$(``
|
||||
# must survive intact.
|
||||
assert guard._strip_subshell("kill $(pgrep -f myapp)") == "kill $(pgrep -f myapp)"
|
||||
result = guard.classify_recovery_command("kill $(pgrep -f myapp)")
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is True
|
||||
|
||||
|
||||
def test_redirection_is_not_treated_as_a_background_separator():
|
||||
assert guard._split_segments("a 2>&1") == ["a 2>&1"]
|
||||
assert guard._split_segments("a &> log") == ["a &> log"]
|
||||
assert guard._split_segments("pkill -f mcp_server.py 2>&1") == [
|
||||
"pkill -f mcp_server.py 2>&1"
|
||||
]
|
||||
result = guard.classify_recovery_command("pkill -f mcp_server.py 2>&1")
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
|
||||
|
||||
# ── no false positives ───────────────────────────────────────────────────────
|
||||
|
||||
def test_read_only_inspection_is_not_a_kill():
|
||||
result = guard.classify_recovery_command("ps aux | grep mcp_server")
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_grepping_for_pkill_is_not_a_kill():
|
||||
result = guard.classify_recovery_command('grep -rn "pkill" native_mcp_preference.py')
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_unrelated_pkill_target_is_not_contamination():
|
||||
result = guard.classify_recovery_command("pkill -f my-dev-server")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_user_scoped_pkill_of_unrelated_app_is_not_contamination():
|
||||
# ``-u`` consumes ``mcpuser``; the surviving operand names no daemon (#787).
|
||||
result = guard.classify_recovery_command("pkill -u mcpuser -f myapp")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_commit_message_quoting_the_kill_string_is_not_a_kill():
|
||||
result = guard.classify_recovery_command(
|
||||
'git commit -m "block pkill -f mcp_server.py as workflow recovery"'
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_bare_kill_of_unknown_pid_is_ambiguous_not_contamination():
|
||||
result = guard.classify_recovery_command("kill 31337")
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is True
|
||||
assert "not known MCP" in " ".join(result["reasons"])
|
||||
|
||||
|
||||
def test_kill_without_pid_is_ambiguous():
|
||||
result = guard.classify_recovery_command("kill")
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is True
|
||||
|
||||
|
||||
def test_empty_command_is_inert():
|
||||
result = guard.classify_recovery_command(None)
|
||||
assert result["command_present"] is False
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
# ── sanctioned reconnect / restart ───────────────────────────────────────────
|
||||
|
||||
def test_sanctioned_reconnect_is_not_contamination():
|
||||
result = guard.classify_recovery_command(
|
||||
"/mcp reconnect then re-run gitea_whoami"
|
||||
)
|
||||
assert result["sanctioned_recovery"] is True
|
||||
assert result["contamination"] is False
|
||||
assert result["process_kill"] is False
|
||||
|
||||
|
||||
def test_stale_runtime_restart_language_is_not_contamination():
|
||||
result = guard.classify_recovery_command(
|
||||
"runtime is stale against master; relaunch the IDE client so the "
|
||||
"namespaces restart"
|
||||
)
|
||||
assert result["sanctioned_recovery"] is True
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_sanctioned_language_never_excuses_an_actual_kill():
|
||||
result = guard.classify_recovery_command(
|
||||
"client reconnect did not help; pkill -f mcp_server.py"
|
||||
)
|
||||
assert result["sanctioned_recovery"] is True
|
||||
assert result["contamination"] is True
|
||||
|
||||
|
||||
# ── operator authorization (env-only, never self-assertable) ─────────────────
|
||||
|
||||
def test_operator_authorization_absent_by_default():
|
||||
auth = guard.operator_authorization(env={})
|
||||
assert auth["authorized"] is False
|
||||
assert auth["reference"] is None
|
||||
assert auth["self_assertable"] is False
|
||||
|
||||
|
||||
def test_operator_authorization_read_from_env_only():
|
||||
auth = guard.operator_authorization(env={AUTH_ENV: "CHG-4471 host maintenance"})
|
||||
assert auth["authorized"] is True
|
||||
assert auth["reference"] == "CHG-4471 host maintenance"
|
||||
assert auth["source"] == AUTH_ENV
|
||||
|
||||
|
||||
def test_authorized_maintenance_is_not_contamination():
|
||||
assessment = guard.assess_recovery_command(
|
||||
"pkill -f mcp_server.py",
|
||||
env={AUTH_ENV: "CHG-4471"},
|
||||
)
|
||||
assert assessment["classification"]["contamination"] is True
|
||||
assert assessment["contaminated"] is False
|
||||
assert assessment["authorized_bypass"] is True
|
||||
assert assessment["remediation"] is None
|
||||
|
||||
|
||||
def test_unauthorized_kill_is_contamination():
|
||||
assessment = guard.assess_recovery_command("pkill -f mcp_server.py", env={})
|
||||
assert assessment["contaminated"] is True
|
||||
assert assessment["authorized_bypass"] is False
|
||||
assert assessment["remediation"]
|
||||
|
||||
|
||||
# ── redaction ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_marker_and_classification_redact_secrets():
|
||||
command = "GITEA_TOKEN=supersecretvalue pkill -f mcp_server.py"
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert "supersecretvalue" not in result["redacted_command"]
|
||||
assert "GITEA_TOKEN=***" in result["redacted_command"]
|
||||
record = guard.build_contamination_record(
|
||||
reason_class=guard.REASON_MANUAL_DAEMON_KILL,
|
||||
command_redacted=result["redacted_command"],
|
||||
)
|
||||
assert "supersecretvalue" not in record["command_summary"]
|
||||
assert record["cleared_by_reconciler"] is False
|
||||
|
||||
|
||||
# ── AC3: gate over the gated mutation set ────────────────────────────────────
|
||||
|
||||
def test_gate_blocks_gated_tasks():
|
||||
marker = _marker()
|
||||
for task in ("merge_pr", "review_pr", "close_issue", "create_pr", "submit_pr_review"):
|
||||
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
|
||||
assert gate["block"] is True, task
|
||||
|
||||
|
||||
def test_gate_allows_handoff_tasks():
|
||||
marker = _marker()
|
||||
for task in ("comment_issue", "lock_issue"):
|
||||
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
|
||||
assert gate["block"] is False, task
|
||||
|
||||
|
||||
def test_gate_exempts_reconciler():
|
||||
gate = guard.assess_contamination_gate(
|
||||
_marker(), task="merge_pr", actual_role="reconciler"
|
||||
)
|
||||
assert gate["block"] is False
|
||||
|
||||
|
||||
def test_gate_allows_when_no_marker_or_cleared():
|
||||
assert guard.assess_contamination_gate(
|
||||
None, task="merge_pr", actual_role="author"
|
||||
)["block"] is False
|
||||
cleared = _marker(cleared_by_reconciler=True)
|
||||
assert guard.assess_contamination_gate(
|
||||
cleared, task="merge_pr", actual_role="author"
|
||||
)["block"] is False
|
||||
|
||||
|
||||
def test_gate_error_message_names_the_issue():
|
||||
gate = guard.assess_contamination_gate(
|
||||
_marker(), task="merge_pr", actual_role="author"
|
||||
)
|
||||
assert "#630" in guard.format_contamination_gate_error(gate)
|
||||
|
||||
|
||||
# ── scope item 4: final-report rules ─────────────────────────────────────────
|
||||
|
||||
def test_final_report_clean_claim_is_rejected():
|
||||
result = guard.assess_final_report_claim(
|
||||
"Runtime recovery: manual daemon kill occurred. Otherwise a clean session.",
|
||||
_marker(),
|
||||
)
|
||||
assert result["block"] is True
|
||||
assert result["clean_claim"] is True
|
||||
|
||||
|
||||
def test_final_report_must_surface_the_contamination():
|
||||
result = guard.assess_final_report_claim(
|
||||
"All acceptance criteria met; tests pass.", _marker()
|
||||
)
|
||||
assert result["block"] is True
|
||||
assert result["surfaced"] is False
|
||||
|
||||
|
||||
def test_final_report_that_surfaces_and_claims_nothing_clean_passes():
|
||||
result = guard.assess_final_report_claim(
|
||||
"This session performed a manual daemon kill of the MCP processes and "
|
||||
"is workflow-contaminated pending a reconciler audit.",
|
||||
_marker(),
|
||||
)
|
||||
assert result["block"] is False
|
||||
assert result["surfaced"] is True
|
||||
|
||||
|
||||
def test_final_report_unconstrained_without_marker():
|
||||
result = guard.assess_final_report_claim("clean session", None)
|
||||
assert result["block"] is False
|
||||
assert result["contaminated"] is False
|
||||
|
||||
|
||||
def test_validator_blocks_clean_claim_while_contaminated():
|
||||
out = final_report_validator.assess_final_report_validator(
|
||||
"Merged the PR. No contamination in this session.",
|
||||
"merge_pr",
|
||||
runtime_recovery_marker=_marker(),
|
||||
)
|
||||
assert out["blocked"] is True
|
||||
assert any(
|
||||
finding["rule_id"] == "shared.runtime_recovery_contamination"
|
||||
for finding in out["findings"]
|
||||
)
|
||||
|
||||
|
||||
def test_validator_default_is_unchanged_without_marker():
|
||||
out = final_report_validator.assess_final_report_validator(
|
||||
"Merged the PR. No contamination in this session.",
|
||||
"merge_pr",
|
||||
)
|
||||
assert "runtime_recovery_contamination" not in out["checks"]
|
||||
assert not any(
|
||||
finding["rule_id"] == "shared.runtime_recovery_contamination"
|
||||
for finding in out["findings"]
|
||||
)
|
||||
|
||||
|
||||
# ── durable marker must outlive the session TTL ──────────────────────────────
|
||||
|
||||
def test_contamination_marker_is_recovery_critical():
|
||||
assert (
|
||||
mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION
|
||||
in mcp_session_state.RECOVERY_CRITICAL_KINDS
|
||||
)
|
||||
|
||||
|
||||
# ── server wiring: record tool ───────────────────────────────────────────────
|
||||
|
||||
def test_record_tool_marks_manual_daemon_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||
assert loaded is not None
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_marks_background_separator_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="sleep 1 & pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||
assert loaded is not None
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_marks_subshell_wrapped_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="(pkill -f mcp_server.py)", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||
assert loaded is not None
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_does_not_mark_a_quoted_mention_of_the_kill_string():
|
||||
# The marker is what fails review/merge/close closed and only a reconciler
|
||||
# may clear it, so a quoted mention must never create one (PR #789 F1).
|
||||
for command in F1_QUOTED_COMMANDS:
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(command=command, remote="prgs")
|
||||
assert res["contaminated"] is False, command
|
||||
assert res["marked"] is False, command
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None, command
|
||||
|
||||
|
||||
def test_record_tool_marks_broad_sweep():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f python", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_BROAD_PROCESS_KILL
|
||||
|
||||
|
||||
def test_record_tool_marks_known_pid_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="kill -9 4242", mcp_pids=["4242"], remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
|
||||
|
||||
def test_record_tool_does_not_mark_inspection():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="ps aux | grep mcp_server", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is False
|
||||
assert res["marked"] is False
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None
|
||||
|
||||
|
||||
def test_record_tool_does_not_mark_sanctioned_reconnect():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="/mcp reconnect", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is False
|
||||
assert res["marked"] is False
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None
|
||||
|
||||
|
||||
def test_record_tool_mark_false_is_read_only():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs", mark=False
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is False
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None
|
||||
|
||||
|
||||
def test_record_tool_honours_operator_authorization():
|
||||
_clear_marker()
|
||||
with patch.dict(os.environ, {AUTH_ENV: "CHG-4471"}):
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
assert res["authorized_bypass"] is True
|
||||
assert res["contaminated"] is False
|
||||
assert res["marked"] is False
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None
|
||||
|
||||
|
||||
# ── server wiring: audit tool ────────────────────────────────────────────────
|
||||
|
||||
def test_audit_inspect_reports_marker():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
out = srv.gitea_audit_runtime_recovery_contamination(action="inspect", remote="prgs")
|
||||
assert out["contaminated"] is True
|
||||
assert out["read_only"] is True
|
||||
|
||||
|
||||
def test_audit_clear_refused_for_non_reconciler():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
with patch.object(srv, "_actual_profile_role", return_value="author"):
|
||||
out = srv.gitea_audit_runtime_recovery_contamination(
|
||||
action="clear", remote="prgs"
|
||||
)
|
||||
assert out["success"] is False
|
||||
assert out["reasons"]
|
||||
assert srv._load_runtime_recovery_marker("prgs") is not None
|
||||
|
||||
|
||||
def test_audit_clear_allowed_for_reconciler():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
identity = srv._runtime_recovery_profile_identity()
|
||||
with patch.object(srv, "_actual_profile_role", return_value="reconciler"):
|
||||
out = srv.gitea_audit_runtime_recovery_contamination(
|
||||
action="clear", remote="prgs", profile_identity=identity
|
||||
)
|
||||
assert out["success"] is True
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None
|
||||
|
||||
|
||||
def test_audit_unknown_action_fails_closed():
|
||||
out = srv.gitea_audit_runtime_recovery_contamination(action="nuke", remote="prgs")
|
||||
assert out["success"] is False
|
||||
assert out["performed"] is False
|
||||
|
||||
|
||||
# ── AC3/AC4: contaminated post-restart mutation fails closed ─────────────────
|
||||
|
||||
def _force_gate_env():
|
||||
return patch.dict(os.environ, {"GITEA_TEST_FORCE_RUNTIME_CONTAMINATION": "1"})
|
||||
|
||||
|
||||
def test_gate_blocks_mutations_after_manual_kill_and_restart():
|
||||
_clear_marker()
|
||||
# The session kills the daemons, the IDE respawns them, and the session then
|
||||
# attempts the mutations #601 was closed with.
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
||||
for task in ("merge_pr", "review_pr", "close_issue", "create_pr"):
|
||||
try:
|
||||
srv._enforce_runtime_recovery_contamination_gate(task, "prgs")
|
||||
raised = False
|
||||
except RuntimeError as exc:
|
||||
raised = True
|
||||
assert "#630" in str(exc)
|
||||
assert raised, task
|
||||
|
||||
|
||||
def test_gate_allows_handoff_comment_when_contaminated():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
||||
srv._enforce_runtime_recovery_contamination_gate("comment_issue", "prgs")
|
||||
srv._enforce_runtime_recovery_contamination_gate("lock_issue", "prgs")
|
||||
|
||||
|
||||
def test_gate_exempts_reconciler_audit():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="reconciler"):
|
||||
srv._enforce_runtime_recovery_contamination_gate("merge_pr", "prgs")
|
||||
|
||||
|
||||
def test_gate_noop_after_sanctioned_restart_only():
|
||||
_clear_marker()
|
||||
srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="/mcp reconnect", remote="prgs"
|
||||
)
|
||||
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
||||
srv._enforce_runtime_recovery_contamination_gate("merge_pr", "prgs")
|
||||
@@ -0,0 +1,430 @@
|
||||
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||
|
||||
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||
* touch mcp_config.json (or any MCP client config)
|
||||
* spawn recovery threads
|
||||
* call os._exit / terminate the serving process
|
||||
* claim that an auto-restart was triggered
|
||||
|
||||
Recovery is owned by the IDE/client reconnect path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
ROLE_PROFILES = (
|
||||
("create_issue", "prgs-author", "author"),
|
||||
("review_pr", "prgs-reviewer", "reviewer"),
|
||||
("merge_pr", "prgs-merger", "merger"),
|
||||
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||
mock_getpid = MagicMock(return_value=12345)
|
||||
mock_exists = MagicMock(return_value=True)
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
mock_run_env = MagicMock()
|
||||
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||
|
||||
mock_run_git = MagicMock()
|
||||
mock_run_git.stdout = "SAME"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
return mock_run_env
|
||||
if args[0] == "ps":
|
||||
return mock_run_ps
|
||||
if args[0] == "git":
|
||||
return mock_run_git
|
||||
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||
|
||||
mock_run = MagicMock(side_effect=side_effect)
|
||||
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
|
||||
|
||||
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
@patch("threading.Thread")
|
||||
@patch("os._exit")
|
||||
def test_stale_self_does_not_touch_config_or_exit(
|
||||
self,
|
||||
mock_exit,
|
||||
mock_thread,
|
||||
mock_utime,
|
||||
mock_getpid,
|
||||
mock_exists,
|
||||
mock_getmtime,
|
||||
mock_run,
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
before_threads = threading.active_count()
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
after_threads = threading.active_count()
|
||||
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||
for r in reasons),
|
||||
reasons,
|
||||
)
|
||||
# Must not claim auto-restart / config touch
|
||||
blob = " ".join(reasons)
|
||||
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||
self.assertNotIn("touched mcp_config", blob)
|
||||
self.assertNotIn("will cleanly exit", blob)
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
self.assertEqual(before_threads, after_threads)
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
for _ in range(5):
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
|
||||
def test_trigger_mcp_auto_restart_removed(self):
|
||||
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.close",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": profile_name,
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
profile_name
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
return result, mock_utime, mock_thread, mock_exit
|
||||
|
||||
def test_stale_returns_typed_blocker_fields(self):
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
self.assertTrue(result.get("stop_required"), result)
|
||||
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||
self.assertIs(result.get("mutation_performed"), False)
|
||||
action = result.get("exact_safe_next_action") or ""
|
||||
self.assertIn("reconnect", action.lower())
|
||||
self.assertNotIn("None; ready for operations", action)
|
||||
reason = result.get("reason") or ""
|
||||
self.assertIn("stale-runtime", reason)
|
||||
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||
for task, profile, role in ROLE_PROFILES:
|
||||
with self.subTest(task=task, profile=profile):
|
||||
# Skip tasks that may be unknown on this branch
|
||||
try:
|
||||
import task_capability_map as tcm
|
||||
|
||||
tcm.required_permission(task)
|
||||
except Exception:
|
||||
self.skipTest(f"task {task} not in capability map")
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = (
|
||||
self._resolve_with_stale_runtime(task, profile, role)
|
||||
)
|
||||
self.assertTrue(
|
||||
result.get("restart_required") or result.get("stop_required"),
|
||||
result,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_config_mtime_and_contents_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = os.path.join(tmp, "mcp_config.json")
|
||||
original = '{"servers": {"gitea-author": {}}}'
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
fh.write(original)
|
||||
mtime_before = os.path.getmtime(cfg)
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
# Force-path also must not use real utime when diagnostics runs
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(after, original)
|
||||
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||
mock_utime.assert_not_called()
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
|
||||
|
||||
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||
def test_parity_stale_still_reports_restart_required(self):
|
||||
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||
import master_parity_gate as mpg
|
||||
|
||||
out = mpg.assess_master_parity(
|
||||
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
)
|
||||
self.assertFalse(out.get("in_parity"))
|
||||
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||
|
||||
|
||||
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||
def test_resolve_docstring_declares_side_effect_free(self):
|
||||
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||
lower = doc.lower()
|
||||
self.assertTrue(
|
||||
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||
doc,
|
||||
)
|
||||
self.assertNotIn("auto-restart", lower)
|
||||
self.assertNotIn("os._exit", lower)
|
||||
|
||||
|
||||
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
|
||||
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
|
||||
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def test_resolve_uses_report_only_stale_binding_assessment(self):
|
||||
"""Capability resolve must call stale-binding assess with auto_recover=False."""
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.merge",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
"prgs-author"
|
||||
)
|
||||
fake_binding = {
|
||||
"classification": "missing_path",
|
||||
"active_worktree": "/tmp/gone",
|
||||
"reasons": ["path missing"],
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"_assess_stale_active_binding",
|
||||
return_value=fake_binding,
|
||||
) as mock_assess, patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
|
||||
mock_assess.assert_called()
|
||||
# Every call must be report-only (no env clear / session-state write).
|
||||
for call in mock_assess.call_args_list:
|
||||
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user