Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b00e09a781 |
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from control_plane_db import (
|
from control_plane_db import (
|
||||||
ControlPlaneDB,
|
ControlPlaneDB,
|
||||||
@@ -40,6 +42,31 @@ OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
|||||||
OUTCOME_NO_SAFE = "no_safe_work"
|
OUTCOME_NO_SAFE = "no_safe_work"
|
||||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
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_AUTHOR = "author"
|
||||||
ROLE_REVIEWER = "reviewer"
|
ROLE_REVIEWER = "reviewer"
|
||||||
@@ -135,9 +162,76 @@ class SkipRecord:
|
|||||||
kind: str
|
kind: str
|
||||||
number: int
|
number: int
|
||||||
reason: str
|
reason: str
|
||||||
|
reason_code: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
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:
|
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||||
@@ -194,8 +288,16 @@ def classify_skip(
|
|||||||
*,
|
*,
|
||||||
role: str,
|
role: str,
|
||||||
terminal_pr: int | None,
|
terminal_pr: int | None,
|
||||||
|
claim_ownership: str | None = None,
|
||||||
) -> str | 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"):
|
if c.state in ("merged", "closed"):
|
||||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||||
if c.blocked or "status:blocked" in c.labels:
|
if c.blocked or "status:blocked" in c.labels:
|
||||||
@@ -205,6 +307,16 @@ def classify_skip(
|
|||||||
c.dependency_reason
|
c.dependency_reason
|
||||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
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:
|
if c.already_claimed_elsewhere:
|
||||||
return f"{c.kind}#{c.number} already claimed elsewhere"
|
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
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]:
|
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(
|
return sorted(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
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(
|
def allocate_next_work(
|
||||||
db: ControlPlaneDB,
|
db: ControlPlaneDB,
|
||||||
*,
|
*,
|
||||||
@@ -262,12 +497,22 @@ def allocate_next_work(
|
|||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
lease_ttl_seconds: int | 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]:
|
) -> dict[str, Any]:
|
||||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||||
|
|
||||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
*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.
|
Never uses file locks or comment-only leases as the assignment source.
|
||||||
"""
|
"""
|
||||||
if db is None:
|
if db is None:
|
||||||
@@ -303,6 +548,7 @@ def allocate_next_work(
|
|||||||
role=role_norm,
|
role=role_norm,
|
||||||
profile=profile_name,
|
profile=profile_name,
|
||||||
pid=os.getpid(),
|
pid=os.getpid(),
|
||||||
|
controller_instance_id=controller_instance_id,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — surface structured
|
except Exception as exc: # noqa: BLE001 — surface structured
|
||||||
return {
|
return {
|
||||||
@@ -344,30 +590,238 @@ def allocate_next_work(
|
|||||||
}
|
}
|
||||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
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] = []
|
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
|
selected: WorkCandidate | None = None
|
||||||
for c in ordered:
|
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:
|
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
|
continue
|
||||||
selected = c
|
selected = c
|
||||||
break
|
break
|
||||||
|
|
||||||
if selected is None:
|
if selected is None:
|
||||||
# If terminal lock blocks all review work, surface that explicitly.
|
# 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):
|
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
outcome = OUTCOME_BLOCKED_TERMINAL
|
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||||
reasons = [
|
reasons = [
|
||||||
f"no safe work for role '{role_norm}': active terminal-review "
|
f"no safe work for role '{role_norm}': active terminal-review "
|
||||||
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
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:
|
else:
|
||||||
outcome = OUTCOME_NO_SAFE
|
outcome = OUTCOME_NO_SAFE
|
||||||
reasons = [
|
reasons = [
|
||||||
f"no safe assignable work for role '{role_norm}' "
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -389,6 +843,13 @@ def allocate_next_work(
|
|||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_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": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents"
|
||||||
@@ -435,6 +896,12 @@ def allocate_next_work(
|
|||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_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": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents"
|
||||||
@@ -558,6 +1025,12 @@ def allocate_next_work(
|
|||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_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": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"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:
|
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(
|
return WorkCandidate(
|
||||||
kind=str(data.get("kind") or "issue"),
|
kind=str(data.get("kind") or "issue"),
|
||||||
number=int(data["number"]),
|
number=number,
|
||||||
state=str(data.get("state") or "open"),
|
state=str(data.get("state") or "open"),
|
||||||
labels=tuple(data.get("labels") or ()),
|
labels=tuple(data.get("labels") or ()),
|
||||||
title=str(data.get("title") or ""),
|
title=str(data.get("title") or ""),
|
||||||
priority=int(data.get("priority") or 0),
|
priority=priority,
|
||||||
head_sha=data.get("head_sha"),
|
head_sha=data.get("head_sha"),
|
||||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||||
|
|||||||
+21
-2
@@ -33,6 +33,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import author_mutation_worktree
|
import author_mutation_worktree
|
||||||
|
import create_issue_bootstrap
|
||||||
import master_parity_gate
|
import master_parity_gate
|
||||||
import remote_repo_guard
|
import remote_repo_guard
|
||||||
import root_checkout_guard
|
import root_checkout_guard
|
||||||
@@ -76,6 +77,7 @@ MUTATION_TASKS = frozenset({
|
|||||||
"create_issue",
|
"create_issue",
|
||||||
"comment_issue",
|
"comment_issue",
|
||||||
"close_issue",
|
"close_issue",
|
||||||
|
"edit_issue",
|
||||||
"mark_issue",
|
"mark_issue",
|
||||||
"lock_issue",
|
"lock_issue",
|
||||||
"set_issue_labels",
|
"set_issue_labels",
|
||||||
@@ -354,6 +356,7 @@ def assess_anti_stomp_preflight(
|
|||||||
remote_master_sha: str | None = None,
|
remote_master_sha: str | None = None,
|
||||||
check_root_checkout: bool = True,
|
check_root_checkout: bool = True,
|
||||||
check_worktree: bool = True,
|
check_worktree: bool = True,
|
||||||
|
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||||
# stale runtime (master parity)
|
# stale runtime (master parity)
|
||||||
startup_head: str | None = None,
|
startup_head: str | None = None,
|
||||||
current_code_head: str | None = None,
|
current_code_head: str | None = None,
|
||||||
@@ -584,12 +587,28 @@ def assess_anti_stomp_preflight(
|
|||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
current_branch=current_branch,
|
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"] = {
|
checks["worktree"] = {
|
||||||
"block": bool(wt.get("block")),
|
"block": bool(wt.get("block")) and not bootstrap_waived,
|
||||||
"reasons": list(wt.get("reasons") or []),
|
"reasons": list(wt.get("reasons") or []),
|
||||||
"under_branches": wt.get("under_branches"),
|
"under_branches": wt.get("under_branches"),
|
||||||
|
"create_issue_bootstrap_waived": bool(bootstrap_waived),
|
||||||
}
|
}
|
||||||
if wt.get("block"):
|
if wt.get("block") and not bootstrap_waived:
|
||||||
blockers.append(
|
blockers.append(
|
||||||
_blocker(
|
_blocker(
|
||||||
BLOCKER_WRONG_WORKTREE,
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
|||||||
+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
|
Author/coder mutations must run from a session-owned worktree under the
|
||||||
project's ``branches/`` directory, never from the stable control checkout.
|
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
|
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
|
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||||
# via namespace_workspace_binding (#510).
|
# 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:
|
def _normalize_path(path: str) -> str:
|
||||||
return (path or "").replace("\\", "/").rstrip("/")
|
return (path or "").replace("\\", "/").rstrip("/")
|
||||||
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
|
|||||||
active_worktree_env: str | None = None,
|
active_worktree_env: str | None = None,
|
||||||
author_worktree_env: str | None = None,
|
author_worktree_env: str | None = None,
|
||||||
) -> str:
|
) -> 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):
|
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||||
text = (candidate or "").strip()
|
text = (candidate or "").strip()
|
||||||
if text:
|
if text:
|
||||||
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
|
|||||||
f"Branches-only mutation guard (#274): {reasons}. "
|
f"Branches-only mutation guard (#274): {reasons}. "
|
||||||
f"project root: {root}; workspace: {workspace}. "
|
f"project root: {root}; workspace: {workspace}. "
|
||||||
"Create a session-owned worktree under branches/ before mutating."
|
"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}"
|
||||||
|
)
|
||||||
|
|||||||
+438
-14
@@ -29,7 +29,9 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Iterator, Sequence
|
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.
|
# Assignable work kinds only — raw monitoring incidents are never work items.
|
||||||
WORK_KINDS = frozenset({"issue", "pr"})
|
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)
|
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);
|
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_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);
|
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)
|
conn.executescript(_SCHEMA_SQL)
|
||||||
self._migrate_incident_links_null_scope(conn)
|
self._migrate_incident_links_null_scope(conn)
|
||||||
self._migrate_lease_lifecycle_columns(conn)
|
self._migrate_lease_lifecycle_columns(conn)
|
||||||
|
self._migrate_session_ownership_columns(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
||||||
("schema_version", str(SCHEMA_VERSION)),
|
("schema_version", str(SCHEMA_VERSION)),
|
||||||
@@ -492,32 +529,62 @@ class ControlPlaneDB:
|
|||||||
namespace: str | None = None,
|
namespace: str | None = None,
|
||||||
pid: int | None = None,
|
pid: int | None = None,
|
||||||
status: str = "active",
|
status: str = "active",
|
||||||
|
controller_instance_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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()
|
now = _ts()
|
||||||
|
instance = (controller_instance_id or "").strip() or None
|
||||||
with self._tx() as conn:
|
with self._tx() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT session_id FROM sessions WHERE session_id = ?",
|
"SELECT session_id FROM sessions WHERE session_id = ?",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
conn.execute(
|
if instance is None:
|
||||||
"""
|
conn.execute(
|
||||||
UPDATE sessions
|
"""
|
||||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
UPDATE sessions
|
||||||
last_heartbeat_at = ?, status = ?
|
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||||
WHERE session_id = ?
|
last_heartbeat_at = ?, status = ?
|
||||||
""",
|
WHERE session_id = ?
|
||||||
(role, profile, namespace, pid, now, status, 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:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions(
|
INSERT INTO sessions(
|
||||||
session_id, role, profile, namespace, pid,
|
session_id, role, profile, namespace, pid,
|
||||||
started_at, last_heartbeat_at, status
|
started_at, last_heartbeat_at, status,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
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(
|
row = conn.execute(
|
||||||
"SELECT * FROM sessions WHERE session_id = ?",
|
"SELECT * FROM sessions WHERE session_id = ?",
|
||||||
@@ -1215,6 +1282,73 @@ class ControlPlaneDB:
|
|||||||
if name not in cols:
|
if name not in cols:
|
||||||
conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}")
|
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]:
|
def _lease_columns(self, conn: sqlite3.Connection) -> set[str]:
|
||||||
return {
|
return {
|
||||||
row[1]
|
row[1]
|
||||||
@@ -1256,7 +1390,8 @@ class ControlPlaneDB:
|
|||||||
w.number AS work_number, w.state AS work_state,
|
w.number AS work_number, w.state AS work_state,
|
||||||
w.current_head_sha AS work_head_sha,
|
w.current_head_sha AS work_head_sha,
|
||||||
s.pid AS session_pid, s.profile AS session_profile,
|
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
|
FROM leases l
|
||||||
JOIN work_items w ON w.work_item_id = l.work_item_id
|
JOIN work_items w ON w.work_item_id = l.work_item_id
|
||||||
LEFT JOIN sessions s ON s.session_id = l.session_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}"
|
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 replace CI or code review for MCP changes
|
||||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
- 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.”
|
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.
|
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.
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,44 @@ Least-privilege constraints:
|
|||||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||||
`issue.close`, which the production normalizer rejects or drops).
|
`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
|
Launch a static `gitea-reconciler` MCP namespace with
|
||||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
`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
|
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||||
canonical label specs and status transition replacement behavior.
|
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
|
||||||
|
|
||||||
Discussion issues must be labeled `type:discussion`.
|
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.
|
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
|
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
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
|
## 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
|
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||||
be adapted to other repositories.
|
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
|
## Principle: the profile is the role, not the LLM
|
||||||
|
|
||||||
```text
|
```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
|
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||||
update tests if behavior changes; run the full suite; commit with an
|
update tests if behavior changes; run the full suite; commit with an
|
||||||
issue-linked message; open a PR to `master`; move the issue to
|
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 Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
- **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
|
## 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).
|
- [`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).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
|||||||
| Option | Description |
|
| 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`. |
|
| 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. |
|
| 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). |
|
| 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). |
|
| 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. |
|
| 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. |
|
| 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
|
## Placeholder-only entries
|
||||||
|
|
||||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
**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
|
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||||
reconnect.
|
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
|
## Related
|
||||||
|
|
||||||
|
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
||||||
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||||
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||||
- `docs/mcp-client-registration.md` — per-server registration contract.
|
- `docs/mcp-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.
|
||||||
@@ -131,7 +131,59 @@ and `incident_links` rows.
|
|||||||
- The bridge remains the **only** sanctioned route from an alert back into
|
- The bridge remains the **only** sanctioned route from an alert back into
|
||||||
Gitea workflow state.
|
Gitea workflow state.
|
||||||
|
|
||||||
## 7. Non-goals
|
## 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** become the workflow source of truth.
|
||||||
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||||
|
|||||||
@@ -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)
|
||||||
+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 issue_lock_provenance
|
||||||
import merger_lease_adoption
|
import merger_lease_adoption
|
||||||
import reviewer_handoff_consistency
|
import reviewer_handoff_consistency
|
||||||
|
import runtime_recovery_guard
|
||||||
import thread_state_ledger_validator
|
import thread_state_ledger_validator
|
||||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||||
from post_merge_cleanup_proof import assess_post_merge_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 (
|
from review_proofs import (
|
||||||
HANDOFF_HEADING,
|
HANDOFF_HEADING,
|
||||||
assess_controller_handoff,
|
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]]:
|
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||||
from conflict_fix_classification import (
|
from conflict_fix_classification import (
|
||||||
assess_conflict_fix_classification_final_report,
|
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 = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
@@ -1584,13 +1663,24 @@ _SHARED_CANONICAL_COMMENT_RULES = (
|
|||||||
_rule_shared_canonical_comment_post_claim,
|
_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]]]]] = {
|
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||||
"review_pr": [
|
"review_pr": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_legacy_workspace_mutations,
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_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,
|
_rule_reviewer_stale_head_proof,
|
||||||
],
|
],
|
||||||
"merge_pr": [
|
"merge_pr": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
@@ -1631,11 +1722,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_stale_head_proof,
|
_rule_reviewer_stale_head_proof,
|
||||||
],
|
],
|
||||||
"reconcile_already_landed": [
|
"reconcile_already_landed": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_reconcile_controller_handoff,
|
_rule_reconcile_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
*_SHARED_CLEANUP_PROOF_RULES,
|
*_SHARED_CLEANUP_PROOF_RULES,
|
||||||
_rule_reconcile_stale_author_fields,
|
_rule_reconcile_stale_author_fields,
|
||||||
@@ -1650,20 +1743,24 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_audit_reconciliation_boundary,
|
_rule_audit_reconciliation_boundary,
|
||||||
],
|
],
|
||||||
"author_issue": [
|
"author_issue": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
],
|
],
|
||||||
"work_issue": [
|
"work_issue": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_shared_issue_acceptance_gate,
|
_rule_shared_issue_acceptance_gate,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_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,
|
_rule_worktree_cleanup_audit_proof,
|
||||||
],
|
],
|
||||||
"issue_filing": [
|
"issue_filing": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
"inventory": [
|
"inventory": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reconcile_pagination_proof,
|
_rule_reconcile_pagination_proof,
|
||||||
],
|
],
|
||||||
"issue_selection": [
|
"issue_selection": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_TWO_COMMENT_RULES,
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
|
*_SHARED_MUTATION_BUDGET_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
# Controller issue closure (#529): a closure report must not bury an
|
# 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
|
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||||
# full reviewer/author handoff schema.
|
# full reviewer/author handoff schema.
|
||||||
"controller_close": [
|
"controller_close": [
|
||||||
|
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||||
_rule_reviewer_premerge_baseline_proof,
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1766,6 +1870,8 @@ def assess_final_report_validator(
|
|||||||
session_pr_opened: bool = False,
|
session_pr_opened: bool = False,
|
||||||
validation_session: dict | None = None,
|
validation_session: dict | None = None,
|
||||||
reconciler_close_lock: 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]:
|
) -> dict[str, Any]:
|
||||||
"""Validate final-report text against task-specific proof rules (#327).
|
"""Validate final-report text against task-specific proof rules (#327).
|
||||||
|
|
||||||
@@ -1804,6 +1910,28 @@ def assess_final_report_validator(
|
|||||||
action_log = sanitized_action_log
|
action_log = sanitized_action_log
|
||||||
findings.extend(action_log_findings)
|
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:
|
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
||||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||||
report_text,
|
report_text,
|
||||||
@@ -1829,6 +1957,7 @@ def assess_final_report_validator(
|
|||||||
"session_pr_opened": session_pr_opened,
|
"session_pr_opened": session_pr_opened,
|
||||||
"validation_session": validation_session,
|
"validation_session": validation_session,
|
||||||
"reconciler_close_lock": reconciler_close_lock,
|
"reconciler_close_lock": reconciler_close_lock,
|
||||||
|
"mutation_attempt_ledger": mutation_attempt_ledger,
|
||||||
}
|
}
|
||||||
|
|
||||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||||
|
|||||||
+2956
-212
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)
|
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:
|
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
|
||||||
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
||||||
eg_org = str(existing.get("gitea_org") or "")
|
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]]
|
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||||
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
# 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(
|
def reconcile_incident(
|
||||||
db: ControlPlaneDB | None,
|
db: ControlPlaneDB | None,
|
||||||
@@ -523,6 +598,7 @@ def reconcile_incident(
|
|||||||
mapping: ProjectMapping | None = None,
|
mapping: ProjectMapping | None = None,
|
||||||
apply: bool = False,
|
apply: bool = False,
|
||||||
create_issue_fn: CreateIssueFn | None = None,
|
create_issue_fn: CreateIssueFn | None = None,
|
||||||
|
comment_issue_fn: CommentIssueFn | None = None,
|
||||||
force_gitea_issue_number: int | None = None,
|
force_gitea_issue_number: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Reconcile one observation into incident_links + optional Gitea issue.
|
"""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
|
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||||
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
``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.
|
Never creates control-plane ``work_items`` for raw incidents.
|
||||||
"""
|
"""
|
||||||
base: dict[str, Any] = {
|
base: dict[str, Any] = {
|
||||||
@@ -549,6 +630,7 @@ def reconcile_incident(
|
|||||||
"gitea_issue": None,
|
"gitea_issue": None,
|
||||||
"action": None,
|
"action": None,
|
||||||
"mapping": None,
|
"mapping": None,
|
||||||
|
"recurrence_comment": None,
|
||||||
"substrate": "control_plane_db.incident_links",
|
"substrate": "control_plane_db.incident_links",
|
||||||
"durable_work_system": "gitea_issues",
|
"durable_work_system": "gitea_issues",
|
||||||
}
|
}
|
||||||
@@ -652,10 +734,14 @@ def reconcile_incident(
|
|||||||
# --- apply path ---
|
# --- apply path ---
|
||||||
issue_number: int | None = None
|
issue_number: int | None = None
|
||||||
created = False
|
created = False
|
||||||
|
recurrence: tuple[bool, str] | None = None
|
||||||
if existing:
|
if existing:
|
||||||
issue_number = int(existing["gitea_issue_number"])
|
issue_number = int(existing["gitea_issue_number"])
|
||||||
action = "updated_existing_link"
|
action = "updated_existing_link"
|
||||||
outcome = OUTCOME_UPDATED
|
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:
|
elif force_gitea_issue_number is not None:
|
||||||
issue_number = int(force_gitea_issue_number)
|
issue_number = int(force_gitea_issue_number)
|
||||||
action = "link_explicit_issue"
|
action = "link_explicit_issue"
|
||||||
@@ -729,6 +815,63 @@ def reconcile_incident(
|
|||||||
}
|
}
|
||||||
return base
|
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["success"] = True
|
||||||
base["performed"] = True
|
base["performed"] = True
|
||||||
base["db_mutated"] = 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
|
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(
|
def assess_own_branch_adoption(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
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)"
|
||||||
|
)
|
||||||
+45
-2
@@ -148,8 +148,37 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
def lock_generation(lock: dict[str, Any] | None) -> int:
|
||||||
"""Persist a keyed lock and bind it to the current process session."""
|
"""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,
|
||||||
|
) -> 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 "")
|
remote = str(lock_data.get("remote") or "")
|
||||||
org = str(lock_data.get("org") or "")
|
org = str(lock_data.get("org") or "")
|
||||||
repo = str(lock_data.get("repo") or "")
|
repo = str(lock_data.get("repo") or "")
|
||||||
@@ -194,6 +223,20 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
|||||||
)
|
)
|
||||||
if lease_block:
|
if lease_block:
|
||||||
raise RuntimeError(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(path, record)
|
||||||
save_lock_file(session_pointer_path(root), pointer)
|
save_lock_file(session_pointer_path(root), pointer)
|
||||||
except LockContentionError as exc:
|
except LockContentionError as exc:
|
||||||
|
|||||||
+214
-3
@@ -20,16 +20,208 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|||||||
def resolve_author_worktree_path(
|
def resolve_author_worktree_path(
|
||||||
explicit: str | None,
|
explicit: str | None,
|
||||||
project_root: str,
|
project_root: str,
|
||||||
|
*,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
) -> str:
|
) -> 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()
|
path = (explicit or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
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:
|
if not path:
|
||||||
path = project_root
|
path = project_root
|
||||||
return os.path.realpath(os.path.abspath(path))
|
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(
|
def read_worktree_git_state(
|
||||||
worktree_path: str,
|
worktree_path: str,
|
||||||
extra_bases: tuple[str, ...] | list[str] = (),
|
extra_bases: tuple[str, ...] | list[str] = (),
|
||||||
@@ -92,8 +284,19 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_branches: frozenset[str] | None = None,
|
base_branches: frozenset[str] | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> 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.
|
||||||
|
"""
|
||||||
bases = base_branches or BASE_BRANCHES
|
bases = base_branches or BASE_BRANCHES
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
path = (worktree_path or "").strip()
|
path = (worktree_path or "").strip()
|
||||||
@@ -111,7 +314,11 @@ def assess_issue_lock_worktree(
|
|||||||
f"(dirty files: {', '.join(dirty_files)})"
|
f"(dirty files: {', '.join(dirty_files)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
if base_equivalent is False:
|
if recovery_sanctioned:
|
||||||
|
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||||
|
# against the durable lock record instead (#753).
|
||||||
|
pass
|
||||||
|
elif base_equivalent is False:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"issue lock worktree must be base-equivalent to one of "
|
"issue lock worktree must be base-equivalent to one of "
|
||||||
f"{_base_list(bases)} before implementation work; inspected "
|
f"{_base_list(bases)} before implementation work; inspected "
|
||||||
@@ -139,6 +346,7 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root=inspected_git_root,
|
inspected_git_root=inspected_git_root,
|
||||||
base_branch=base_branch,
|
base_branch=base_branch,
|
||||||
base_equivalent=base_equivalent,
|
base_equivalent=base_equivalent,
|
||||||
|
recovery_sanctioned=recovery_sanctioned,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +405,7 @@ def _assessment(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_equivalent: bool | None = None,
|
base_equivalent: bool | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"proven": proven,
|
"proven": proven,
|
||||||
@@ -208,6 +417,8 @@ def _assessment(
|
|||||||
"dirty_files": dirty_files,
|
"dirty_files": dirty_files,
|
||||||
"base_branch": base_branch,
|
"base_branch": base_branch,
|
||||||
"base_equivalent": base_equivalent,
|
"base_equivalent": base_equivalent,
|
||||||
|
"recovery_sanctioned": recovery_sanctioned,
|
||||||
|
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
import issue_claim_heartbeat as claim_hb
|
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)
|
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(
|
def _matching_branches(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
branch_names: list[str],
|
branch_names: list[str],
|
||||||
@@ -52,8 +174,15 @@ def assess_work_issue_duplicate_gate(
|
|||||||
claim_entry: dict | None = None,
|
claim_entry: dict | None = None,
|
||||||
locked_branch: str | None = None,
|
locked_branch: str | None = None,
|
||||||
phase: str = PHASE_LOCK,
|
phase: str = PHASE_LOCK,
|
||||||
|
recovered_owning_pr: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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] = []
|
reasons: list[str] = []
|
||||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||||
prs = list(open_prs or [])
|
prs = list(open_prs or [])
|
||||||
@@ -61,12 +190,23 @@ def assess_work_issue_duplicate_gate(
|
|||||||
pattern = _issue_pattern(issue_number)
|
pattern = _issue_pattern(issue_number)
|
||||||
|
|
||||||
linked = _linked_open_pr(issue_number, prs)
|
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:
|
if linked:
|
||||||
reasons.append(
|
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
|
||||||
f"open PR #{linked.get('number')} already covers issue "
|
issue_number,
|
||||||
f"#{issue_number} (fail closed)"
|
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(
|
conflicting_branches = _matching_branches(
|
||||||
issue_number, branches, locked_branch=locked_branch
|
issue_number, branches, locked_branch=locked_branch
|
||||||
@@ -122,6 +262,9 @@ def assess_work_issue_duplicate_gate(
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
"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,
|
"conflicting_branches": conflicting_branches,
|
||||||
"claim_status": status or None,
|
"claim_status": status or None,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
|
|||||||
+43
-15
@@ -19,6 +19,32 @@ print_banner() {
|
|||||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
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() {
|
show_root_checkout_health() {
|
||||||
printf '\n--- Project status / root checkout health ---\n\n'
|
printf '\n--- Project status / root checkout health ---\n\n'
|
||||||
printf 'Current directory: %s\n' "$(pwd)"
|
printf 'Current directory: %s\n' "$(pwd)"
|
||||||
@@ -241,25 +267,27 @@ main_menu() {
|
|||||||
while true; do
|
while true; do
|
||||||
print_banner
|
print_banner
|
||||||
printf ' 1) Project status / root checkout health\n'
|
printf ' 1) Project status / root checkout health\n'
|
||||||
printf ' 2) Author workflow prompts\n'
|
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
|
||||||
printf ' 3) Reviewer workflow prompts\n'
|
printf ' 3) Author workflow prompts\n'
|
||||||
printf ' 4) Merger workflow prompts\n'
|
printf ' 4) Reviewer workflow prompts\n'
|
||||||
printf ' 5) Reconciler workflow prompts\n'
|
printf ' 5) Merger workflow prompts\n'
|
||||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
printf ' 6) Reconciler workflow prompts\n'
|
||||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
printf ' 7) Onboarding new project to this MCP workflow\n'
|
||||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
printf ' 8) Proxmox deployment menu placeholder\n'
|
||||||
printf ' 9) Run tests\n'
|
printf ' 9) Create Proxmox LXC placeholder\n'
|
||||||
|
printf ' t) Run tests\n'
|
||||||
printf ' 0) Exit\n'
|
printf ' 0) Exit\n'
|
||||||
read -r -p 'Choice: ' choice
|
read -r -p 'Choice: ' choice
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
1) show_root_checkout_health ;;
|
1) show_root_checkout_health ;;
|
||||||
2) show_author_prompts ;;
|
2) show_workflow_dashboard_help ;;
|
||||||
3) show_reviewer_prompts ;;
|
3) show_author_prompts ;;
|
||||||
4) show_merger_prompts ;;
|
4) show_reviewer_prompts ;;
|
||||||
5) show_reconciler_prompts ;;
|
5) show_merger_prompts ;;
|
||||||
6) show_onboarding_prompt ;;
|
6) show_reconciler_prompts ;;
|
||||||
7|8) show_proxmox_placeholder ;;
|
7) show_onboarding_prompt ;;
|
||||||
9) run_tests ;;
|
8|9) show_proxmox_placeholder ;;
|
||||||
|
t|T|tests) run_tests ;;
|
||||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||||
*) printf 'Invalid choice.\n'; pause ;;
|
*) printf 'Invalid choice.\n'; pause ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ KIND_REVIEW_DRAFT = "review_draft"
|
|||||||
# other session proofs; a contaminated session fails closed on gated mutations
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
# until a reconciler audits and clears it.
|
# until a reconciler audits and clears it.
|
||||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
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
|
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
||||||
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||||
# daemon that dies without teardown leaves provable orphan evidence (owner
|
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||||
@@ -71,6 +76,10 @@ RECOVERY_CRITICAL_KINDS = frozenset(
|
|||||||
# #702 crash-orphan evidence (must outlive TTL; F4)
|
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||||
KIND_REVIEWER_SESSION_LEASE,
|
KIND_REVIEWER_SESSION_LEASE,
|
||||||
KIND_STALE_BINDING_RECOVERY,
|
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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
+157
-39
@@ -55,18 +55,26 @@ def resolve_namespace_workspace(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
demotions: list[str] | None = None,
|
demotions: list[str] | None = None,
|
||||||
verify_paths: bool = False,
|
verify_paths: bool = False,
|
||||||
|
durable_author_result: dict | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> 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
|
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||||
are demoted (#702): a binding to a deleted worktree can never name a
|
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||||
valid task workspace, so resolution falls through to the next candidate.
|
can never name a valid task workspace, so resolution falls through to the
|
||||||
Explicit arguments are never demoted — a caller-declared path must fail
|
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||||
loudly downstream rather than silently rebind. Demotion notes are
|
path must fail loudly downstream rather than silently rebind. Demotion
|
||||||
appended to *demotions* when provided. Runtime-context and mutation
|
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
|
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||||
always verifies.
|
always verifies.
|
||||||
"""
|
"""
|
||||||
@@ -74,6 +82,32 @@ def resolve_namespace_workspace(
|
|||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||||
|
|
||||||
|
# #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 (
|
for candidate, source, env_sourced in (
|
||||||
(worktree_path, "worktree_path argument", False),
|
(worktree_path, "worktree_path argument", False),
|
||||||
(worktree, "worktree argument", False),
|
(worktree, "worktree argument", False),
|
||||||
@@ -83,6 +117,11 @@ def resolve_namespace_workspace(
|
|||||||
f"{role_env_key} environment variable", True),
|
f"{role_env_key} environment variable", True),
|
||||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||||
"reviewer PR lease worktree", False),
|
"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()
|
text = (candidate or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -107,6 +146,7 @@ def resolve_namespace_mutation_context(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
worktree: str | None = None,
|
worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
@@ -119,21 +159,54 @@ def resolve_namespace_mutation_context(
|
|||||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||||
repository the namespace actually mutates. Without it the single-repo
|
repository the namespace actually mutates. Without it the single-repo
|
||||||
default is preserved: the canonical root follows the process checkout.
|
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] = []
|
demotions: list[str] = []
|
||||||
workspace, binding_source = resolve_namespace_workspace(
|
env_map = env if env is not None else os.environ
|
||||||
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,
|
|
||||||
demotions=demotions,
|
|
||||||
verify_paths=True,
|
|
||||||
)
|
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
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(
|
pollution = assess_foreign_role_worktree_pollution(
|
||||||
role_kind=role,
|
role_kind=role,
|
||||||
resolved_workspace=workspace,
|
resolved_workspace=workspace,
|
||||||
@@ -141,12 +214,7 @@ def resolve_namespace_mutation_context(
|
|||||||
env=env,
|
env=env,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
)
|
)
|
||||||
configured = (configured_canonical_root or "").strip()
|
result = {
|
||||||
if configured:
|
|
||||||
canonical_root = os.path.realpath(configured)
|
|
||||||
else:
|
|
||||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
|
||||||
return {
|
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"workspace_binding_source": binding_source,
|
"workspace_binding_source": binding_source,
|
||||||
"workspace_role_kind": role,
|
"workspace_role_kind": role,
|
||||||
@@ -155,6 +223,17 @@ def resolve_namespace_mutation_context(
|
|||||||
"canonical_repo_root": canonical_root,
|
"canonical_repo_root": canonical_root,
|
||||||
"roots_aligned": canonical_root == process_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(
|
def assess_foreign_role_worktree_pollution(
|
||||||
@@ -231,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
|||||||
reasons: list[str] | None = None,
|
reasons: list[str] | None = None,
|
||||||
ignored_bindings: list[str] | None = None,
|
ignored_bindings: list[str] | None = None,
|
||||||
dirty_files: list[str] | None = None,
|
dirty_files: list[str] | None = None,
|
||||||
|
operator_recovery: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||||
role = normalize_role_kind(role_kind)
|
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 = [
|
parts = [
|
||||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||||
f"resolved workspace '{workspace}' via {binding_source}."
|
f"resolved workspace '{workspace}' via {binding_source}."
|
||||||
@@ -249,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
|||||||
+ ", ".join(dirty_files)
|
+ ", ".join(dirty_files)
|
||||||
+ "."
|
+ "."
|
||||||
)
|
)
|
||||||
if reasons:
|
if reason_list:
|
||||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||||
parts.append(
|
if operator_recovery:
|
||||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
parts.append(f"Operator recovery: {operator_recovery}")
|
||||||
f"branches/ {role} worktree, set "
|
else:
|
||||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
parts.append(
|
||||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||||
"reset foreign role worktrees to unblock this namespace."
|
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)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -269,6 +370,7 @@ def assess_namespace_mutation_workspace(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
@@ -281,6 +383,7 @@ def assess_namespace_mutation_workspace(
|
|||||||
process_project_root=process_project_root,
|
process_project_root=process_project_root,
|
||||||
env=env,
|
env=env,
|
||||||
session_lease_worktree=session_lease_worktree,
|
session_lease_worktree=session_lease_worktree,
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
configured_canonical_root=configured_canonical_root,
|
configured_canonical_root=configured_canonical_root,
|
||||||
)
|
)
|
||||||
@@ -305,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
|||||||
)
|
)
|
||||||
|
|
||||||
reasons = list(metadata.get("reasons") or [])
|
reasons = list(metadata.get("reasons") or [])
|
||||||
|
operator_recovery = ctx.get("operator_recovery")
|
||||||
if role == "author":
|
if role == "author":
|
||||||
branches = amw.assess_author_mutation_worktree(
|
# #618 durable resolution already validated existence, membership,
|
||||||
workspace_path=mutation_workspace,
|
# branches/, lock ownership, and traversal safety when present.
|
||||||
project_root=ctx["canonical_repo_root"],
|
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||||
current_branch=current_branch,
|
if durable_reasons:
|
||||||
)
|
reasons.extend(durable_reasons)
|
||||||
if branches["block"]:
|
elif ctx.get("author_worktree_block"):
|
||||||
reasons.extend(branches["reasons"])
|
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 (
|
elif (
|
||||||
role == "reviewer"
|
role == "reviewer"
|
||||||
and mutation_workspace == process_root
|
and mutation_workspace == process_root
|
||||||
@@ -345,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
|||||||
"metadata_only": metadata.get("metadata_only", False),
|
"metadata_only": metadata.get("metadata_only", False),
|
||||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
"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,
|
||||||
|
}
|
||||||
+213
-12
@@ -41,6 +41,186 @@ _DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "
|
|||||||
UPDATE_STYLE_MERGE = "merge"
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
_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:
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
text = (value or "").strip().lower()
|
text = (value or "").strip().lower()
|
||||||
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
|
|||||||
"branch_protection_requires_current_base": requires_current,
|
"branch_protection_requires_current_base": requires_current,
|
||||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
"checks_status": checks,
|
"checks_status": checks,
|
||||||
|
"checks_required": bool(checks_required),
|
||||||
"active_locks_and_leases": {
|
"active_locks_and_leases": {
|
||||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
"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,
|
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||||
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
|
|||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
# ``checks_required`` is derived from the live branch-protection policy by
|
||||||
if checks in ("pending", "running", "queued"):
|
# 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})")
|
reasons.append(f"required checks are not finished (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks in _CTX_FAILURE:
|
||||||
return result
|
|
||||||
if checks in ("failure", "failed", "error", "cancelled"):
|
|
||||||
reasons.append(f"required checks failed (status={checks})")
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks == CHECKS_MISSING_REQUIRED:
|
||||||
return result
|
reasons.append(
|
||||||
# unknown — fail closed when checks_required
|
"branch protection configures required status context(s) but no "
|
||||||
if checks == "unknown":
|
"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)")
|
reasons.append("checks status unknown (fail closed)")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
else:
|
||||||
return result
|
# 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 ────────────────────────────────────
|
# ── Ready to merge without update ────────────────────────────────────
|
||||||
# Includes: current with approval; outdated when update is NOT required.
|
# Includes: current with approval; outdated when update is NOT required.
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ RECONCILER_TASKS = frozenset({
|
|||||||
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||||
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||||
"delete_branch",
|
"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_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -118,6 +123,9 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
"cleanup_merged_pr_branch": "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.
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||||
"reconcile_close_landed_pr": "reconciler",
|
"reconcile_close_landed_pr": "reconciler",
|
||||||
"reconcile_close_landed_issue": "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
|
||||||
@@ -35,3 +35,11 @@ Install for Codex:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
|
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
|
proposed push before running it; `gitea_audit_stable_branch_contamination` to
|
||||||
inspect or (reconciler-only) clear the marker.
|
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
|
## Shell Spawn Hard-Stop Rule
|
||||||
|
|
||||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
`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.
|
- Never place raw tokens in LLM/MCP config.
|
||||||
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
|
- 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
|
## Controller Handoff
|
||||||
|
|
||||||
Every task must end with a section titled exactly `Controller Handoff`. Compact
|
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.
|
for the loaded workflow mode — not the legacy compact block alone.
|
||||||
`review_proofs.assess_controller_handoff()` validates presence.
|
`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
|
## Prompt templates
|
||||||
|
|
||||||
Ready-to-copy task prompts live in [`templates/`](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).
|
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:
|
* Read-only diagnostics:
|
||||||
* Blockers:
|
* Blockers:
|
||||||
* Safe next action: (fresh run for the next PR)
|
* 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:
|
- Git ref mutations:
|
||||||
- MCP/Gitea mutations:
|
- MCP/Gitea mutations:
|
||||||
- Reconciliation mutations:
|
- Reconciliation mutations:
|
||||||
|
- Terminal label cleanup:
|
||||||
- External-state mutations:
|
- External-state mutations:
|
||||||
- Read-only diagnostics:
|
- Read-only diagnostics:
|
||||||
- Blockers:
|
- Blockers:
|
||||||
@@ -50,4 +51,15 @@ occurred).
|
|||||||
|
|
||||||
Identity format: `username / profile` (not personal email unless required — #305).
|
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,
|
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||||
terminal review mutation, merge result, and linked issue status.
|
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-backed claims (#395)
|
||||||
|
|
||||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
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
|
When a claim relies on prior-session blocker state or MCP metadata only, label
|
||||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
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).
|
`Read-only diagnostics` (#297).
|
||||||
|
|
||||||
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
|
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.
|
||||||
@@ -450,6 +450,35 @@ If any gate fails, do not create the issue.
|
|||||||
|
|
||||||
Produce a recovery handoff or duplicate report.
|
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
|
## 19. Issue commenting gate
|
||||||
|
|
||||||
Before commenting on an existing issue, verify:
|
Before commenting on an existing issue, verify:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -36,6 +36,20 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"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": {
|
"create_label": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -158,6 +172,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"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": {
|
"blind_pr_queue_review": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
@@ -415,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-mutating MCP tools and their resolver task keys.
|
||||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||||
"gitea_create_issue": "create_issue",
|
"gitea_create_issue": "create_issue",
|
||||||
"gitea_close_issue": "close_issue",
|
"gitea_close_issue": "close_issue",
|
||||||
|
"gitea_edit_issue": "edit_issue",
|
||||||
"gitea_create_issue_comment": "comment_issue",
|
"gitea_create_issue_comment": "comment_issue",
|
||||||
"gitea_mark_issue": "mark_issue",
|
"gitea_mark_issue": "mark_issue",
|
||||||
"gitea_set_issue_labels": "set_issue_labels",
|
"gitea_set_issue_labels": "set_issue_labels",
|
||||||
|
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||||
"gitea_create_label": "create_label",
|
"gitea_create_label": "create_label",
|
||||||
"gitea_commit_files": "commit_files",
|
"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."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
+11
-1
@@ -238,7 +238,17 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_close_issue_audited(self, _auth, mock_api):
|
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):
|
with patch.dict(os.environ, self._env(), clear=True):
|
||||||
gitea_close_issue(issue_number=42, remote="prgs")
|
gitea_close_issue(issue_number=42, remote="prgs")
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
|
|||||||
@@ -79,18 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_whoami_called = True
|
mcp_server._preflight_whoami_called = True
|
||||||
mcp_server._preflight_capability_called = True
|
mcp_server._preflight_capability_called = True
|
||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
control_root = "/repo/Gitea-Tools"
|
control_root = "/repo/Gitea-Tools"
|
||||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
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(
|
||||||
with mock.patch.dict(
|
"gitea_mcp_server._session_author_lock_worktree",
|
||||||
"os.environ",
|
return_value=None,
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
):
|
||||||
clear=False,
|
with mock.patch(
|
||||||
|
"gitea_auth.get_profile",
|
||||||
|
return_value={"profile_name": "gitea-author"},
|
||||||
):
|
):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with mock.patch.dict(
|
||||||
mcp_server.verify_preflight_purity()
|
"os.environ",
|
||||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
{"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):
|
def test_verify_preflight_allows_branches_worktree(self):
|
||||||
import mcp_server
|
import mcp_server
|
||||||
@@ -98,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_whoami_called = True
|
mcp_server._preflight_whoami_called = True
|
||||||
mcp_server._preflight_capability_called = True
|
mcp_server._preflight_capability_called = True
|
||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
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.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
with mock.patch.dict(
|
with mock.patch.object(
|
||||||
"os.environ",
|
mcp_server, "_session_author_lock_worktree", return_value=None
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
|
||||||
clear=False,
|
|
||||||
):
|
):
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
|
|||||||
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertEqual(rows["schema_version"], "3")
|
self.assertEqual(rows["schema_version"], "4")
|
||||||
self.assertIn("DB coordinates", rows["architecture"])
|
self.assertIn("DB coordinates", rows["architecture"])
|
||||||
self.assertIn("bridge", rows["architecture"].lower())
|
self.assertIn("bridge", rows["architecture"].lower())
|
||||||
|
|
||||||
|
|||||||
@@ -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_whoami_called = True
|
||||||
srv._preflight_capability_called = True
|
srv._preflight_capability_called = True
|
||||||
srv._preflight_resolved_role = "author"
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_resolved_task = "create_issue"
|
||||||
srv._preflight_whoami_violation = False
|
srv._preflight_whoami_violation = False
|
||||||
srv._preflight_capability_violation = False
|
srv._preflight_capability_violation = False
|
||||||
|
|
||||||
# Disable early return in verify_preflight_purity for testing
|
# Disable early return in verify_preflight_purity for testing
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
srv._preflight_in_test_mode = lambda: False
|
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):
|
def tearDown(self):
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
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._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
@@ -51,29 +59,62 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
"porcelain_status": "",
|
"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,
|
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||||
):
|
):
|
||||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
# #749: clean canonical control checkout is the sanctioned create_issue path.
|
||||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
mock_api.return_value = {
|
||||||
|
"number": 77,
|
||||||
|
"html_url": "https://gitea.example.com/issues/77",
|
||||||
|
}
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
try:
|
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||||
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
with patch.object(srv, "_run_anti_stomp_preflight", return_value=None):
|
||||||
except RuntimeError as exc:
|
with patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
self.assertIn("stable control checkout", str(exc))
|
res = srv.gitea_create_issue(
|
||||||
else:
|
title="Test issue", body="body text for gate"
|
||||||
# #683: production guards return typed blockers at entrypoints
|
)
|
||||||
self.assertFalse(res.get("success"))
|
self.assertEqual(res.get("number"), 77)
|
||||||
self.assertFalse(res.get("performed"))
|
mock_api.assert_called_once()
|
||||||
blob = " ".join(res.get("reasons") or []) + " " + str(
|
|
||||||
res.get("blocker_kind") or ""
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
self.assertTrue(
|
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||||
"stable control checkout" in blob
|
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||||
or "missing_issue_worktree" in blob
|
@patch("gitea_mcp_server.api_request")
|
||||||
or "control checkout" in blob.lower()
|
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||||
)
|
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||||
self.assertTrue(res.get("exact_next_action"))
|
@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._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
|||||||
@@ -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,308 @@
|
|||||||
|
"""Regression coverage for issue #723 role and capability invariants."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
|
||||||
|
REVIEWER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
|
||||||
|
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
|
||||||
|
},
|
||||||
|
"prgs-merger": {
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_preflight() -> None:
|
||||||
|
mcp_server._clear_preflight_capability_state()
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_whoami_violation = False
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
mcp_server.role_session_router.clear_route_state()
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolveHarness(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def _resolve(
|
||||||
|
self,
|
||||||
|
task,
|
||||||
|
profile=REVIEWER_PROFILE,
|
||||||
|
required_role=None,
|
||||||
|
init_side_effect=None,
|
||||||
|
):
|
||||||
|
patches = [
|
||||||
|
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="tester"
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"init_review_decision_lock",
|
||||||
|
return_value=None,
|
||||||
|
side_effect=init_side_effect,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_check_mcp_runtimes_diagnostics", return_value=[]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if required_role is not None:
|
||||||
|
patches.append(
|
||||||
|
patch.object(
|
||||||
|
mcp_server.task_capability_map,
|
||||||
|
"required_role",
|
||||||
|
side_effect=lambda candidate: (
|
||||||
|
required_role
|
||||||
|
if candidate == task
|
||||||
|
else task_capability_map.TASK_CAPABILITY_MAP[candidate][
|
||||||
|
"role"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for context in patches:
|
||||||
|
context.__enter__()
|
||||||
|
try:
|
||||||
|
return mcp_server.gitea_resolve_task_capability(
|
||||||
|
task=task, remote="prgs"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for context in reversed(patches):
|
||||||
|
context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityRoleStampSafety(_ResolveHarness):
|
||||||
|
def test_allowed_resolution_records_the_correct_stamp(self):
|
||||||
|
result = self._resolve("review_pr")
|
||||||
|
self.assertTrue(result["allowed_in_current_session"], result)
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||||
|
|
||||||
|
def test_denied_resolution_records_no_stamp(self):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"record_preflight_check",
|
||||||
|
wraps=mcp_server.record_preflight_check,
|
||||||
|
) as record:
|
||||||
|
result = self._resolve("review_pr", required_role="merger")
|
||||||
|
|
||||||
|
self.assertFalse(result["allowed_in_current_session"], result)
|
||||||
|
stamped_calls = [
|
||||||
|
call
|
||||||
|
for call in record.call_args_list
|
||||||
|
if len(call.args) > 1 and call.args[1] is not None
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
stamped_calls,
|
||||||
|
[],
|
||||||
|
"a denied resolution must never transiently record a role stamp",
|
||||||
|
)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||||
|
|
||||||
|
def test_denied_resolution_clears_an_existing_stamp(self):
|
||||||
|
allowed = self._resolve("review_pr")
|
||||||
|
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||||
|
|
||||||
|
denied = self._resolve("merge_pr")
|
||||||
|
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||||
|
|
||||||
|
def test_denial_cannot_poison_a_later_allowed_task(self):
|
||||||
|
denied = self._resolve("merge_pr")
|
||||||
|
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||||
|
|
||||||
|
allowed = self._resolve("review_pr")
|
||||||
|
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||||
|
|
||||||
|
def test_unexpected_resolver_failure_leaves_no_stamp(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "malformed decision state"):
|
||||||
|
self._resolve(
|
||||||
|
"review_pr",
|
||||||
|
init_side_effect=RuntimeError("malformed decision state"),
|
||||||
|
)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStructuredWorkspaceRoleFailures(unittest.TestCase):
|
||||||
|
def test_review_submission_returns_workspace_role_binding_failure(self):
|
||||||
|
error = RuntimeError(
|
||||||
|
"namespace workspace binding blocked: merger role in reviewer workspace"
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
||||||
|
):
|
||||||
|
result = mcp_server._evaluate_pr_review_submission(
|
||||||
|
pr_number=721,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha="8" * 40,
|
||||||
|
remote="prgs",
|
||||||
|
live=True,
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||||
|
self.assertTrue(
|
||||||
|
any("workspace/role binding failed" in reason for reason in result["reasons"]),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
self.assertTrue(any("merger role" in reason for reason in result["reasons"]))
|
||||||
|
|
||||||
|
def test_adopt_merger_lease_returns_workspace_role_binding_failure(self):
|
||||||
|
error = RuntimeError("merger workspace binding rejected")
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "_profile_operation_gate", return_value=[]
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
||||||
|
), patch.object(mcp_server, "_resolve") as resolve:
|
||||||
|
result = mcp_server.gitea_adopt_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/merge-pr-718",
|
||||||
|
expected_head_sha="7" * 40,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["adopted"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||||
|
self.assertEqual(result["pr_number"], 718)
|
||||||
|
self.assertEqual(result["expected_head_sha"], "7" * 40)
|
||||||
|
self.assertIsNone(result["live_head_sha"])
|
||||||
|
self.assertTrue(any("binding rejected" in reason for reason in result["reasons"]))
|
||||||
|
resolve.assert_not_called()
|
||||||
|
|
||||||
|
def test_unexpected_verifier_failure_remains_fail_closed(self):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_verify_role_mutation_workspace",
|
||||||
|
side_effect=ValueError("unexpected verifier state"),
|
||||||
|
), patch.object(mcp_server, "_resolve") as resolve:
|
||||||
|
with self.assertRaisesRegex(ValueError, "unexpected verifier state"):
|
||||||
|
mcp_server._evaluate_pr_review_submission(
|
||||||
|
pr_number=721,
|
||||||
|
action="approve",
|
||||||
|
remote="prgs",
|
||||||
|
live=True,
|
||||||
|
)
|
||||||
|
resolve.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeCapabilityRoleFiltering(unittest.TestCase):
|
||||||
|
def test_runtime_role_filter_denies_permission_bearing_wrong_role(self):
|
||||||
|
allowed = REVIEWER_PROFILE["allowed_operations"] + ["gitea.pr.merge"]
|
||||||
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||||
|
allowed,
|
||||||
|
[],
|
||||||
|
CONFIG,
|
||||||
|
remote="prgs",
|
||||||
|
active_role_kind="reviewer",
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
item
|
||||||
|
for item in capabilities["task_capabilities"]
|
||||||
|
if item["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertTrue(merge_entry["role_exclusive"])
|
||||||
|
self.assertEqual(merge_entry["capability_view"], "role_filtered")
|
||||||
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||||
|
self.assertFalse(capabilities["can_merge_prs"])
|
||||||
|
|
||||||
|
def test_permission_only_view_is_explicit(self):
|
||||||
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||||
|
["gitea.read", "gitea.pr.merge"],
|
||||||
|
[],
|
||||||
|
CONFIG,
|
||||||
|
active_role_kind=None,
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
item
|
||||||
|
for item in capabilities["task_capabilities"]
|
||||||
|
if item["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertEqual(merge_entry["capability_view"], "permission_only")
|
||||||
|
self.assertTrue(merge_entry["allowed_in_current_session"])
|
||||||
|
|
||||||
|
def test_matching_profiles_honor_declared_roles(self):
|
||||||
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||||
|
["gitea.read"],
|
||||||
|
[],
|
||||||
|
CONFIG,
|
||||||
|
active_role_kind="author",
|
||||||
|
)
|
||||||
|
review_entry = next(
|
||||||
|
item
|
||||||
|
for item in capabilities["task_capabilities"]
|
||||||
|
if item["task"] == "review_pr"
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
item
|
||||||
|
for item in capabilities["task_capabilities"]
|
||||||
|
if item["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
review_entry["matching_configured_profiles"], ["prgs-reviewer"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
merge_entry["matching_configured_profiles"], ["prgs-merger"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""Reconciler role binding for post-merge moot-lease cleanup (#745).
|
||||||
|
|
||||||
|
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
|
||||||
|
released`` lease marker but had no capability-map entry and no role gate: entry
|
||||||
|
required only ``gitea.read``, apply required only ``gitea.pr.comment``, so any
|
||||||
|
profile holding the comment permission reached the mutation while the
|
||||||
|
reconciler could not satisfy resolve-exact-task -> mutation.
|
||||||
|
|
||||||
|
These tests pin the fixed contract:
|
||||||
|
|
||||||
|
- the canonical task and its tool-name alias resolve identically
|
||||||
|
(``gitea.pr.comment`` + ``reconciler``);
|
||||||
|
- only a reconciler profile satisfies permission AND role;
|
||||||
|
- ``apply=false`` assessment stays reachable under ``gitea.read`` for any role
|
||||||
|
and mutates nothing (documented, deliberate divergence from the apply path);
|
||||||
|
- ``apply=true`` requires the exact resolved task, the reconciler role, the
|
||||||
|
comment permission, a validated repository binding and matching dry-run
|
||||||
|
evidence;
|
||||||
|
- live/non-moot/superseded/mismatched/malformed leases fail closed;
|
||||||
|
- the dry-run ledger is append-only and cleanup stays idempotent.
|
||||||
|
|
||||||
|
Every fixture is synthetic. No production PR, lease session or marker is used
|
||||||
|
anywhere in this module (see ``TestNoProductionLeaseTouched``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import os # noqa: E402
|
||||||
|
import unittest # noqa: E402
|
||||||
|
from datetime import datetime, timezone # noqa: E402
|
||||||
|
from unittest.mock import patch # noqa: E402
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import post_merge_moot_lease_gate as gate # noqa: E402
|
||||||
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
|
from mcp_server import gitea_cleanup_post_merge_moot_lease # noqa: E402
|
||||||
|
from role_session_router import RECONCILER_TASKS, TASK_REQUIRED_ROLE # noqa: E402
|
||||||
|
from task_capability_map import required_permission, required_role # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PR = 487
|
||||||
|
ISSUE = 485
|
||||||
|
SESSION = "97274-676d20a825c4"
|
||||||
|
HEAD_A = "a" * 40
|
||||||
|
HEAD_B = "d" * 40
|
||||||
|
LEASE_COMMENT_ID = 6603
|
||||||
|
|
||||||
|
CANONICAL_TASK = "cleanup_post_merge_moot_lease"
|
||||||
|
TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
|
||||||
|
|
||||||
|
_BASE_OPS = "gitea.read,gitea.pr.comment"
|
||||||
|
RECONCILER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.close,gitea.branch.delete",
|
||||||
|
}
|
||||||
|
AUTHOR_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-author",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.create,gitea.issue.create",
|
||||||
|
}
|
||||||
|
REVIEWER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.review,gitea.pr.approve",
|
||||||
|
}
|
||||||
|
MERGER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.merge",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permission shape of the configured role profiles (mirrors
|
||||||
|
# tests/test_task_capability_role_invariants.py CANONICAL_ROLE_PROFILES).
|
||||||
|
ROLE_PROFILE_PERMISSIONS = {
|
||||||
|
"author": {"gitea.read", "gitea.pr.comment", "gitea.pr.create",
|
||||||
|
"gitea.issue.create", "gitea.issue.comment", "gitea.issue.close",
|
||||||
|
"gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"},
|
||||||
|
"reviewer": {"gitea.read", "gitea.pr.comment", "gitea.pr.review",
|
||||||
|
"gitea.pr.approve", "gitea.pr.request_changes",
|
||||||
|
"gitea.issue.comment"},
|
||||||
|
"merger": {"gitea.read", "gitea.pr.comment", "gitea.pr.merge",
|
||||||
|
"gitea.issue.comment"},
|
||||||
|
"reconciler": {"gitea.read", "gitea.pr.comment", "gitea.pr.close",
|
||||||
|
"gitea.issue.close", "gitea.branch.delete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed",
|
||||||
|
candidate_head=HEAD_A, comment_id=LEASE_COMMENT_ID):
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo=SLUG,
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree="branches/review-pr487",
|
||||||
|
phase=phase,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
return {"id": comment_id, "body": body, "user": {"login": "sysadmin"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999):
|
||||||
|
"""api_request side effect keyed on method + url; records POSTs."""
|
||||||
|
calls = {"post": []}
|
||||||
|
|
||||||
|
def _side(method, url, auth=None, payload=None, *a, **k):
|
||||||
|
if (method or "").upper() == "POST":
|
||||||
|
calls["post"].append({"url": url, "payload": payload})
|
||||||
|
return {"id": posted_id}
|
||||||
|
if "/comments" in url:
|
||||||
|
return list(comments)
|
||||||
|
if "/pulls/" in url:
|
||||||
|
pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40}
|
||||||
|
if pr_merged:
|
||||||
|
pr["merged"] = True
|
||||||
|
pr["merged_at"] = "2026-07-08T07:46:04Z"
|
||||||
|
return pr
|
||||||
|
if "/issues/" in url:
|
||||||
|
return {"state": "closed" if pr_merged else "open", "number": ISSUE}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return _side, calls
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(comments, *, pr_merged=True, pr_state="closed"):
|
||||||
|
return leases.assess_post_merge_moot_lease(
|
||||||
|
comments, pr_number=PR, pr_merged=pr_merged, pr_state=pr_state,
|
||||||
|
merge_commit_sha="c" * 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1-5. Capability map / router contract
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestCleanupTaskContract(unittest.TestCase):
|
||||||
|
def test_canonical_task_is_reconciler_owned(self):
|
||||||
|
"""1. The reconciler is the role that can resolve the cleanup task."""
|
||||||
|
self.assertEqual(required_permission(CANONICAL_TASK), "gitea.pr.comment")
|
||||||
|
self.assertEqual(required_role(CANONICAL_TASK), "reconciler")
|
||||||
|
|
||||||
|
def test_tool_alias_resolves_to_identical_contract(self):
|
||||||
|
"""5. Alias and canonical task must not diverge."""
|
||||||
|
self.assertEqual(
|
||||||
|
(required_permission(TOOL_ALIAS), required_role(TOOL_ALIAS)),
|
||||||
|
(required_permission(CANONICAL_TASK), required_role(CANONICAL_TASK)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_author_reviewer_merger_cannot_resolve_the_task(self):
|
||||||
|
"""2-4. No non-reconciler role satisfies permission AND role."""
|
||||||
|
for role in ("author", "reviewer", "merger"):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
self.assertNotEqual(required_role(CANONICAL_TASK), role)
|
||||||
|
# They hold the permission — which is exactly why the role gate
|
||||||
|
# is required rather than optional.
|
||||||
|
self.assertIn(
|
||||||
|
"gitea.pr.comment", ROLE_PROFILE_PERMISSIONS[role],
|
||||||
|
"test premise: non-reconciler roles do hold pr.comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_profile_satisfies_permission_and_role(self):
|
||||||
|
self.assertIn(
|
||||||
|
required_permission(CANONICAL_TASK),
|
||||||
|
ROLE_PROFILE_PERMISSIONS[required_role(CANONICAL_TASK)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_router_agrees_with_capability_map(self):
|
||||||
|
for task in (CANONICAL_TASK, TOOL_ALIAS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(task, RECONCILER_TASKS)
|
||||||
|
self.assertEqual(TASK_REQUIRED_ROLE[task], required_role(task))
|
||||||
|
|
||||||
|
def test_unknown_alias_still_rejected(self):
|
||||||
|
for bogus in ("cleanup_post_merge_moot_leases", "cleanup_moot_lease", ""):
|
||||||
|
with self.subTest(task=bogus):
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
required_role(bogus)
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
required_permission(bogus)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Authorization gate unit tests
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestApplyAuthorizationGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self.addCleanup(gate._reset_for_testing)
|
||||||
|
self.assessment = _assessment([_lease_comment()])
|
||||||
|
|
||||||
|
def _evidence(self, **over):
|
||||||
|
base = dict(
|
||||||
|
pr_number=PR, repository_slug=SLUG, lease_moot=True,
|
||||||
|
cleanup_allowed=True, session_id=SESSION, candidate_head=HEAD_A,
|
||||||
|
lease_comment_id=LEASE_COMMENT_ID,
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return gate.record_dry_run(**base)
|
||||||
|
|
||||||
|
def _assess(self, **over):
|
||||||
|
kwargs = dict(
|
||||||
|
pr_number=PR, repository_slug=SLUG, resolved_task=CANONICAL_TASK,
|
||||||
|
active_role_kind="reconciler", assessment=self.assessment,
|
||||||
|
evidence=self._evidence(),
|
||||||
|
)
|
||||||
|
kwargs.update(over)
|
||||||
|
return gate.assess_apply_authorization(**kwargs)
|
||||||
|
|
||||||
|
def test_reconciler_with_matching_evidence_is_authorized(self):
|
||||||
|
result = self._assess()
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
self.assertTrue(result["evidence_matched"])
|
||||||
|
|
||||||
|
def test_apply_without_exact_task_resolution_fails(self):
|
||||||
|
"""8. Apply without exact task resolution fails preflight."""
|
||||||
|
result = self._assess(resolved_task=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
|
||||||
|
def test_resolving_another_task_does_not_authorize_cleanup(self):
|
||||||
|
"""9. A sibling reconciler task is not a substitute."""
|
||||||
|
for other in ("delete_branch", "reconcile_already_landed_pr",
|
||||||
|
"cleanup_merged_pr_branch", "comment_pr"):
|
||||||
|
with self.subTest(task=other):
|
||||||
|
result = self._assess(resolved_task=other)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
|
||||||
|
def test_non_reconciler_roles_fail_closed(self):
|
||||||
|
"""10. Author/reviewer/merger cannot apply despite pr.comment."""
|
||||||
|
for role in ("author", "reviewer", "merger", None, ""):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
result = self._assess(active_role_kind=role)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
|
||||||
|
def test_missing_repository_identity_fails_closed(self):
|
||||||
|
result = self._assess(repository_slug=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
|
||||||
|
def test_missing_dry_run_evidence_fails_closed(self):
|
||||||
|
result = self._assess(evidence=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
|
||||||
|
|
||||||
|
def test_dry_run_that_disallowed_cleanup_fails_closed(self):
|
||||||
|
result = self._assess(evidence=self._evidence(cleanup_allowed=False))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "dry_run_not_allowed")
|
||||||
|
|
||||||
|
def test_dry_run_for_another_pr_or_repo_fails_closed(self):
|
||||||
|
"""11. Wrong PR / repository fails closed."""
|
||||||
|
for over in ({"pr_number": PR + 1}, {"repository_slug": "Other/Repo"}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(evidence=self._evidence(**over))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "dry_run_mismatch")
|
||||||
|
|
||||||
|
def test_superseded_lease_fails_closed(self):
|
||||||
|
"""12. Head/session/marker drift since the dry run fails closed."""
|
||||||
|
for over in ({"session_id": "other-session"},
|
||||||
|
{"candidate_head": HEAD_B},
|
||||||
|
{"lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(evidence=self._evidence(**over))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "superseded_lease")
|
||||||
|
|
||||||
|
def test_expectation_mismatch_fails_closed(self):
|
||||||
|
"""11. Wrong session / head / marker expectations fail closed."""
|
||||||
|
for over in ({"expected_session_id": "nope"},
|
||||||
|
{"expected_candidate_head": HEAD_B},
|
||||||
|
{"expected_lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(**over)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "lease_mismatch")
|
||||||
|
|
||||||
|
def test_matching_expectations_are_authorized(self):
|
||||||
|
result = self._assess(
|
||||||
|
expected_session_id=SESSION,
|
||||||
|
expected_candidate_head=HEAD_A,
|
||||||
|
expected_lease_comment_id=LEASE_COMMENT_ID,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
|
||||||
|
def test_non_moot_lease_fails_closed(self):
|
||||||
|
"""12. A live lease on an open PR is never cleanable."""
|
||||||
|
open_pr = _assessment(
|
||||||
|
[_lease_comment()], pr_merged=False, pr_state="open")
|
||||||
|
result = self._assess(assessment=open_pr)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
result["blocker_kind"], ("lease_not_moot", "malformed_lease"))
|
||||||
|
|
||||||
|
def test_malformed_lease_fails_closed(self):
|
||||||
|
malformed = dict(self.assessment)
|
||||||
|
malformed["active_lease"] = {
|
||||||
|
"session_id": "", "candidate_head": None, "comment_id": None}
|
||||||
|
result = self._assess(assessment=malformed)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "malformed_lease")
|
||||||
|
|
||||||
|
def test_ledger_is_append_only(self):
|
||||||
|
"""14. Recording never rewrites or drops prior entries."""
|
||||||
|
first = self._evidence()
|
||||||
|
second = self._evidence(candidate_head=HEAD_B, lease_comment_id=7777)
|
||||||
|
history = gate.dry_run_history()
|
||||||
|
self.assertEqual(len(history), 2)
|
||||||
|
self.assertEqual(history[0]["candidate_head"], first["candidate_head"])
|
||||||
|
self.assertEqual(history[1]["candidate_head"], HEAD_B)
|
||||||
|
# Newest-wins for lookup, but the older entry survives in history.
|
||||||
|
latest = gate.latest_dry_run(pr_number=PR, repository_slug=SLUG)
|
||||||
|
self.assertEqual(latest["lease_comment_id"], second["lease_comment_id"])
|
||||||
|
self.assertEqual(gate.dry_run_history()[0]["lease_comment_id"],
|
||||||
|
LEASE_COMMENT_ID)
|
||||||
|
|
||||||
|
def test_history_view_cannot_mutate_the_ledger(self):
|
||||||
|
self._evidence()
|
||||||
|
snapshot = gate.dry_run_history()
|
||||||
|
snapshot[0]["pr_number"] = 999999
|
||||||
|
self.assertEqual(
|
||||||
|
gate.dry_run_history()[0]["pr_number"], PR,
|
||||||
|
"dry_run_history must hand out copies, not live rows")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tool-level behavior
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class _ToolCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self.addCleanup(gate._reset_for_testing)
|
||||||
|
|
||||||
|
def _run(self, env, *, apply, comments, pr_state="closed", pr_merged=True,
|
||||||
|
resolved_task=CANONICAL_TASK, slug=SLUG, **kwargs):
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state=pr_state, pr_merged=pr_merged, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=slug), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
resolved_task), \
|
||||||
|
patch.dict(os.environ, env, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=apply, remote="prgs", **kwargs)
|
||||||
|
return result, calls
|
||||||
|
|
||||||
|
|
||||||
|
class TestDryRunOpenToEveryRole(_ToolCase):
|
||||||
|
"""7. Dry run performs no mutation and stays under the read capability."""
|
||||||
|
|
||||||
|
def test_dry_run_reports_moot_and_mutates_nothing_for_every_role(self):
|
||||||
|
for name, env in (("reconciler", RECONCILER_ENV), ("author", AUTHOR_ENV),
|
||||||
|
("reviewer", REVIEWER_ENV), ("merger", MERGER_ENV)):
|
||||||
|
with self.subTest(role=name):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
result, calls = self._run(
|
||||||
|
env, apply=False, comments=[_lease_comment()],
|
||||||
|
resolved_task=None)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["lease_moot"])
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["mode"], "read_only")
|
||||||
|
self.assertEqual(calls["post"], [], "dry run must not mutate")
|
||||||
|
|
||||||
|
def test_dry_run_records_evidence(self):
|
||||||
|
result, _ = self._run(
|
||||||
|
RECONCILER_ENV, apply=False, comments=[_lease_comment()])
|
||||||
|
evidence = result["dry_run_evidence"]
|
||||||
|
self.assertEqual(evidence["pr_number"], PR)
|
||||||
|
self.assertEqual(evidence["repository_slug"], SLUG)
|
||||||
|
self.assertEqual(evidence["session_id"], SESSION)
|
||||||
|
self.assertEqual(evidence["candidate_head"], HEAD_A)
|
||||||
|
self.assertEqual(evidence["lease_comment_id"], LEASE_COMMENT_ID)
|
||||||
|
self.assertTrue(evidence["lease_moot"])
|
||||||
|
self.assertTrue(evidence["cleanup_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyRequiresReconciler(_ToolCase):
|
||||||
|
def test_reconciler_apply_succeeds_after_matching_dry_run(self):
|
||||||
|
"""7 (apply). Allowed dry run then apply posts exactly one marker."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
dry, dry_calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
self.assertTrue(dry["cleanup_allowed"])
|
||||||
|
self.assertEqual(dry_calls["post"], [])
|
||||||
|
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments)
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["released_comment_id"], 9999)
|
||||||
|
self.assertEqual(len(calls["post"]), 1)
|
||||||
|
body = calls["post"][0]["payload"]["body"]
|
||||||
|
self.assertIn("phase: released", body)
|
||||||
|
self.assertIn("post-merge-moot", body)
|
||||||
|
|
||||||
|
def test_apply_without_dry_run_fails_closed(self):
|
||||||
|
"""6. Apply must be preceded by a matching dry run."""
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=[_lease_comment()])
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_apply_without_exact_task_resolution_fails_closed(self):
|
||||||
|
"""8. No resolved cleanup task -> no mutation."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments, resolved_task=None)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_resolving_a_different_task_does_not_authorize_apply(self):
|
||||||
|
"""9. Another resolved task is not a substitute."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments,
|
||||||
|
resolved_task="delete_branch")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_author_reviewer_merger_cannot_apply(self):
|
||||||
|
"""10. Permission-only roles are refused at the role gate."""
|
||||||
|
for name, env in (("author", AUTHOR_ENV), ("reviewer", REVIEWER_ENV),
|
||||||
|
("merger", MERGER_ENV)):
|
||||||
|
with self.subTest(role=name):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(env, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(env, apply=True, comments=comments)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
self.assertEqual(
|
||||||
|
calls["post"], [],
|
||||||
|
f"{name} must not post a terminal lease marker")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyFailsClosedOnLeaseState(_ToolCase):
|
||||||
|
def test_open_pr_lease_is_never_force_cleaned(self):
|
||||||
|
"""12. Non-moot: an active lease on an open PR stays untouched."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments,
|
||||||
|
pr_state="open", pr_merged=False)
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertFalse(result["pr_merged_or_closed"])
|
||||||
|
self.assertEqual(calls["post"], [], "never force-clean an open PR lease")
|
||||||
|
self.assertTrue(any(
|
||||||
|
"still open" in r for r in result.get("cleanup_skipped_reason", [])))
|
||||||
|
|
||||||
|
def test_superseded_lease_between_dry_run_and_apply_fails_closed(self):
|
||||||
|
"""12. The lease moved on after the dry run -> refuse."""
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=[_lease_comment()])
|
||||||
|
moved = [_lease_comment(session_id="fresh-session",
|
||||||
|
candidate_head=HEAD_B, comment_id=7777)]
|
||||||
|
result, calls = self._run(RECONCILER_ENV, apply=True, comments=moved)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "superseded_lease")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_expectation_mismatch_fails_closed(self):
|
||||||
|
"""11. Wrong session / head / marker expectations refuse the apply."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
for kwargs in ({"expected_session_id": "wrong-session"},
|
||||||
|
{"expected_candidate_head": HEAD_B},
|
||||||
|
{"expected_lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**kwargs):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments, **kwargs)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "lease_mismatch")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_already_terminal_cleanup_is_idempotent(self):
|
||||||
|
"""13. A released lease reports nothing to clean and posts nothing."""
|
||||||
|
first = _assessment([_lease_comment()])
|
||||||
|
released = {"id": 7000, "body": first["release_body"],
|
||||||
|
"user": {"login": "sysadmin"}}
|
||||||
|
comments = [_lease_comment(), released]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(RECONCILER_ENV, apply=True, comments=comments)
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertFalse(result["lease_moot"])
|
||||||
|
self.assertEqual(calls["post"], [], "no second terminal marker")
|
||||||
|
self.assertTrue(any(
|
||||||
|
"already released/terminal" in r
|
||||||
|
for r in result.get("cleanup_skipped_reason", [])))
|
||||||
|
|
||||||
|
def test_apply_is_append_only_never_deletes(self):
|
||||||
|
"""14. The only write is a POST; nothing is edited or deleted."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
side, _calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def _recording(method, url, auth=None, payload=None, *a, **k):
|
||||||
|
seen.append((method or "").upper())
|
||||||
|
return side(method, url, auth, payload, *a, **k)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=_recording), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertNotIn("DELETE", seen)
|
||||||
|
self.assertNotIn("PATCH", seen)
|
||||||
|
self.assertNotIn("PUT", seen)
|
||||||
|
self.assertEqual(seen.count("POST"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBinding(_ToolCase):
|
||||||
|
"""11. Foreign-repository targets fail closed before any mutation."""
|
||||||
|
|
||||||
|
def test_explicit_foreign_repository_is_rejected(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._canonical_repository_slug",
|
||||||
|
return_value=(None, [])), \
|
||||||
|
patch("mcp_server._workspace_repository_slug",
|
||||||
|
return_value=SLUG), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs",
|
||||||
|
org="Some-Other-Org", repo="Some-Other-Repo")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_unresolvable_canonical_root_fails_closed(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._canonical_repository_slug",
|
||||||
|
return_value=(None, ["canonical root unresolvable"])), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreflightBinding(_ToolCase):
|
||||||
|
"""4. The apply path binds the shared preflight to the exact task."""
|
||||||
|
|
||||||
|
def test_apply_forwards_task_and_target_to_preflight(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
side, _calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _purity(remote=None, worktree_path=None, task=None, **kw):
|
||||||
|
seen.update({"remote": remote, "worktree_path": worktree_path,
|
||||||
|
"task": task, **kw})
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", _purity), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs",
|
||||||
|
worktree_path="/tmp/branches/reconciler-745",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools")
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertEqual(seen["task"], CANONICAL_TASK)
|
||||||
|
self.assertEqual(seen["worktree_path"], "/tmp/branches/reconciler-745")
|
||||||
|
self.assertEqual(seen["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(seen["repo"], "Gitea-Tools")
|
||||||
|
|
||||||
|
def test_dry_run_does_not_require_preflight(self):
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise AssertionError("dry run must not run mutation preflight")
|
||||||
|
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", _boom), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoProductionLeaseTouched(unittest.TestCase):
|
||||||
|
"""16. No real production lease or PR is referenced by these tests."""
|
||||||
|
|
||||||
|
PRODUCTION_PR = 744
|
||||||
|
PRODUCTION_SESSION = "33673-1d54887a0415"
|
||||||
|
PRODUCTION_MARKER = 12452
|
||||||
|
|
||||||
|
def test_fixtures_are_synthetic(self):
|
||||||
|
self.assertNotEqual(PR, self.PRODUCTION_PR)
|
||||||
|
self.assertNotEqual(SESSION, self.PRODUCTION_SESSION)
|
||||||
|
self.assertNotEqual(LEASE_COMMENT_ID, self.PRODUCTION_MARKER)
|
||||||
|
|
||||||
|
def test_module_source_never_names_the_production_lease(self):
|
||||||
|
source = _Path(__file__).read_text()
|
||||||
|
for token in (self.PRODUCTION_SESSION, str(self.PRODUCTION_MARKER)):
|
||||||
|
self.assertEqual(
|
||||||
|
source.count(token), 1,
|
||||||
|
f"{token!r} must appear only in this guard's own constants",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Regression coverage for the PR checks assessor defect (#751).
|
||||||
|
|
||||||
|
Gitea's *combined* commit status reports ``state: pending`` both when a check is
|
||||||
|
executing and when the status-context collection is empty. The assessor read
|
||||||
|
``state`` alone and defaulted ``checks_required`` to ``True``, so a PR whose head
|
||||||
|
had no status contexts — and never would — was routed to ``blocked`` forever.
|
||||||
|
|
||||||
|
These tests pin the corrected semantics end to end: live branch protection
|
||||||
|
decides whether checks are required, and the actual context collection decides
|
||||||
|
what the checks say.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pr_sync_status # noqa: E402
|
||||||
|
from pr_sync_status import ( # noqa: E402
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
CHECKS_FAILURE,
|
||||||
|
CHECKS_MISSING_REQUIRED,
|
||||||
|
CHECKS_NONE,
|
||||||
|
CHECKS_NOT_REQUIRED,
|
||||||
|
CHECKS_PENDING,
|
||||||
|
CHECKS_SUCCESS,
|
||||||
|
CHECKS_UNKNOWN,
|
||||||
|
assess_pr_sync_status,
|
||||||
|
classify_commit_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(prefix: str) -> str:
|
||||||
|
return (prefix + "0" * 40)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
PR_HEAD = _sha("aaaaaaaa")
|
||||||
|
BASE_HEAD = _sha("bbbbbbbb")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_kwargs(**overrides):
|
||||||
|
data = {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"pr_number": 751,
|
||||||
|
"pr_state": "open",
|
||||||
|
"source_branch": "fix/issue-751-checks-assessor",
|
||||||
|
"pr_head_sha": PR_HEAD,
|
||||||
|
"base_head_sha": BASE_HEAD,
|
||||||
|
"commits_behind": 0,
|
||||||
|
"mergeable": True,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"branch_protection_requires_current_base": False,
|
||||||
|
"approval_at_current_head": True,
|
||||||
|
"checks_status": "success",
|
||||||
|
"active_author_lock": True,
|
||||||
|
"active_reviewer_lease": False,
|
||||||
|
"active_merger_lease": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _reasons(result) -> str:
|
||||||
|
return " | ".join(result["reasons"]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyCommitChecks(unittest.TestCase):
|
||||||
|
"""Pure classification from live evidence."""
|
||||||
|
|
||||||
|
def test_empty_collection_with_combined_pending_is_not_executing_ci(self):
|
||||||
|
# The exact PR #750 failure mode at the classification layer.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NONE)
|
||||||
|
self.assertEqual(result["context_count"], 0)
|
||||||
|
joined = " ".join(result["reasons"]).lower()
|
||||||
|
self.assertIn("empty", joined)
|
||||||
|
self.assertIn("does not indicate", joined)
|
||||||
|
|
||||||
|
def test_protection_disables_status_checks(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=False,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
|
||||||
|
def test_no_required_contexts_aggregates_reported_contexts(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_required_checks_pending(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build", "status": "pending"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
|
||||||
|
def test_required_checks_failed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[{"context": "build", "status": "failure"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_required_checks_successful(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
|
||||||
|
def test_required_context_configured_with_no_matching_result(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "lint", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
self.assertEqual(result["missing_required_contexts"], ["build"])
|
||||||
|
|
||||||
|
def test_mixed_required_and_unrelated_contexts_ignores_unrelated(self):
|
||||||
|
# An unrelated failing context must not fail a satisfied required set.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "optional-scan", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
# ...and a failing *required* context still fails despite passing extras.
|
||||||
|
failing = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
{"context": "optional-scan", "status": "success"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(failing["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_policy_unreadable_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state=None,
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
self.assertIn("fail closed", " ".join(result["reasons"]).lower())
|
||||||
|
|
||||||
|
def test_malformed_policy_indeterminate_flag_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_status_collection_unreadable_fails_closed_when_required(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
status_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_unrecognized_context_state_never_reads_as_success(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "banana"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_newest_wins_per_context(self):
|
||||||
|
# Gitea returns newest-first; the stale failure must not win.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_malformed_status_rows_are_discarded_not_treated_as_passing(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build"}, "not-a-dict", None],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecksGateSemantics(unittest.TestCase):
|
||||||
|
"""``assess_pr_sync_status`` routing and blocker reasons."""
|
||||||
|
|
||||||
|
def test_not_required_allows_merge_now_with_empty_checks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertIn("does not require status checks", _reasons(result))
|
||||||
|
|
||||||
|
def test_none_blocks_when_checks_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NONE),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no status context", _reasons(result))
|
||||||
|
self.assertIn("not executing ci", _reasons(result))
|
||||||
|
|
||||||
|
def test_missing_required_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_MISSING_REQUIRED),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no matching status result", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_pending_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_PENDING), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("not finished", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_failure_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_FAILURE), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("failed", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_success_merges(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_SUCCESS), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_unknown_blocks_when_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_UNKNOWN), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unknown", _reasons(result))
|
||||||
|
|
||||||
|
def test_unrecognized_status_does_not_fall_through_to_merge(self):
|
||||||
|
# Regression: the previous gate only handled a fixed vocabulary and let
|
||||||
|
# anything else reach merge_now.
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status="totally-unexpected"),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unrecognized checks status", _reasons(result))
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_approval_requirement(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED, approval_at_current_head=False
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_current_base_protection(self):
|
||||||
|
# Existing current-base behavior stays intact (#727).
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED,
|
||||||
|
commits_behind=3,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_conflicts(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED, mergeable=False),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchProtectionPolicy(unittest.TestCase):
|
||||||
|
"""Live protection reader derives the status-check requirement."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def _policy(self, responses):
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
for fragment, payload in responses.items():
|
||||||
|
if fragment in url:
|
||||||
|
if isinstance(payload, Exception):
|
||||||
|
raise payload
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake_api_request):
|
||||||
|
return self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {"h": "1"}, base_branch="master"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_checks_enabled_with_contexts(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
"enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build", " "],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertTrue(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
self.assertEqual(policy["required_contexts"], ["ci/build"])
|
||||||
|
|
||||||
|
def test_status_checks_disabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"enable_status_check": False,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_no_protection_rule_means_checks_not_required(self):
|
||||||
|
# PR #750's repository shape: protection list readable but empty.
|
||||||
|
policy = self._policy({"branch_protections": [], "branches/master": {}})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["protection_found"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertIsNone(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_protection_without_status_field_means_not_enabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": RuntimeError("boom"),
|
||||||
|
})
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
self.assertIsNone(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_missing_branch_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", return_value=[]):
|
||||||
|
policy = self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch=" "
|
||||||
|
)
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
|
||||||
|
def test_requires_current_base_accessor_preserved(self):
|
||||||
|
def fake(method, url, auth, *a, **k):
|
||||||
|
if "branch_protections" in url:
|
||||||
|
return [{"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake):
|
||||||
|
value = self.gms._branch_protection_requires_current_base(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch="master"
|
||||||
|
)
|
||||||
|
self.assertTrue(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitChecksSnapshot(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def test_reads_state_and_context_collection(self):
|
||||||
|
payload = {"state": "pending", "statuses": [{"context": "b",
|
||||||
|
"status": "pending"}]}
|
||||||
|
with patch.object(self.gms, "api_request", return_value=payload):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["combined_state"], "pending")
|
||||||
|
self.assertEqual(len(snap["statuses"]), 1)
|
||||||
|
|
||||||
|
def test_empty_collection_recorded_as_determinable(self):
|
||||||
|
with patch.object(
|
||||||
|
self.gms, "api_request", return_value={"state": "pending", "statuses": []}
|
||||||
|
):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["statuses"], [])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=RuntimeError("x")):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertFalse(snap["determinable"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpWrapperForwarding(unittest.TestCase):
|
||||||
|
"""The production MCP path must reach the derived ``checks_required``."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
self.base = (
|
||||||
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patches(self, fake_api_request, spy):
|
||||||
|
return [
|
||||||
|
patch.object(self.gms, "_profile_operation_gate", return_value=None),
|
||||||
|
patch.object(self.gms, "_resolve", return_value=(
|
||||||
|
"gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")),
|
||||||
|
patch.object(self.gms, "_auth", return_value={"Authorization": "x"}),
|
||||||
|
patch.object(self.gms, "repo_api_url", return_value=self.base),
|
||||||
|
patch.object(self.gms, "api_request", side_effect=fake_api_request),
|
||||||
|
patch.object(self.gms, "gitea_get_pr_review_feedback", return_value={
|
||||||
|
"success": True, "approval_at_current_head": True}),
|
||||||
|
patch.object(self.gms, "_prove_author_ownership_for_pr", return_value={
|
||||||
|
"has_author_lock": True}),
|
||||||
|
patch.object(pr_sync_status, "assess_pr_sync_status", side_effect=spy),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _run(self, *, protections, status_payload, pr_number=750,
|
||||||
|
caller_checks_status=None):
|
||||||
|
captured = {}
|
||||||
|
real_assess = pr_sync_status.assess_pr_sync_status
|
||||||
|
|
||||||
|
def spy(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return real_assess(**kwargs)
|
||||||
|
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
if f"/pulls/{pr_number}" in url:
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"title": "t",
|
||||||
|
"body": "b",
|
||||||
|
"mergeable": True,
|
||||||
|
"head": {"sha": PR_HEAD, "ref": "fix/x"},
|
||||||
|
"base": {"sha": BASE_HEAD, "ref": "master"},
|
||||||
|
}
|
||||||
|
if "/branch_protections" in url:
|
||||||
|
if isinstance(protections, Exception):
|
||||||
|
raise protections
|
||||||
|
return protections
|
||||||
|
if "/branches/master" in url:
|
||||||
|
return {"commit": {"id": BASE_HEAD}}
|
||||||
|
if "/status" in url:
|
||||||
|
if isinstance(status_payload, Exception):
|
||||||
|
raise status_payload
|
||||||
|
return status_payload
|
||||||
|
if "/compare/" in url:
|
||||||
|
return {"total_commits": 0}
|
||||||
|
if "/comments" in url:
|
||||||
|
return []
|
||||||
|
return None
|
||||||
|
|
||||||
|
stack = self._patches(fake_api_request, spy)
|
||||||
|
for p in stack:
|
||||||
|
p.start()
|
||||||
|
try:
|
||||||
|
result = self.gms.gitea_assess_pr_sync_status(
|
||||||
|
pr_number=pr_number, remote="prgs",
|
||||||
|
checks_status=caller_checks_status,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in reversed(stack):
|
||||||
|
p.stop()
|
||||||
|
return result, captured
|
||||||
|
|
||||||
|
def test_derived_checks_required_is_forwarded(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "pending", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "pending"}]},
|
||||||
|
)
|
||||||
|
self.assertIn("checks_required", captured)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_pr750_empty_status_with_no_protection_is_merge_ready(self):
|
||||||
|
# The exact reported failure: combined pending, zero contexts, and no
|
||||||
|
# branch protection requiring checks.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertFalse(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_protection_read_failure_blocks(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=RuntimeError("protection unavailable"),
|
||||||
|
status_payload={"state": "success", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_caller_supplied_status_cannot_mask_live_required_failure(self):
|
||||||
|
# A caller-declared "success" must not override live evidence.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "failure", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "failure"}]},
|
||||||
|
caller_checks_status="success",
|
||||||
|
)
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_FAILURE)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertEqual(
|
||||||
|
result["checks_evidence"]["caller_supplied_checks_status"], "success"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_evidence_is_reported_without_secrets(self):
|
||||||
|
result, _ = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
evidence = result["checks_evidence"]
|
||||||
|
self.assertEqual(evidence["context_count"], 0)
|
||||||
|
self.assertEqual(evidence["combined_state"], "pending")
|
||||||
|
self.assertFalse(evidence["protection_found"])
|
||||||
|
blob = repr(result).lower()
|
||||||
|
self.assertNotIn("authorization", blob)
|
||||||
|
self.assertNotIn("token", blob)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Dead-session author issue-lock recovery (#753).
|
||||||
|
|
||||||
|
Covers the narrow recovery path that lets an author re-acquire a durable lock
|
||||||
|
after the MCP session that recorded it exits, plus every rejection condition
|
||||||
|
that must keep failing closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import issue_lock_worktree # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 4242
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-demo"
|
||||||
|
WORKTREE = "/scratch/wt"
|
||||||
|
HEAD = "a" * 40
|
||||||
|
OTHER_SHA = "b" * 40
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "example-author"
|
||||||
|
|
||||||
|
|
||||||
|
def dead_pid() -> int:
|
||||||
|
"""A PID that has certainly exited (spawned, then reaped)."""
|
||||||
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||||
|
proc.wait()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def future_ts(hours: int = 4) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_lock(**overrides):
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"session_pid": dead_pid(),
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": future_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lock.update(overrides)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def assess(lock=None, **overrides):
|
||||||
|
kwargs = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"profile": PROFILE,
|
||||||
|
"current_branch": BRANCH,
|
||||||
|
"porcelain_status": "",
|
||||||
|
"head_sha": HEAD,
|
||||||
|
"remote_head_sha": HEAD,
|
||||||
|
"pr_head_sha": HEAD,
|
||||||
|
"pr_number": 99,
|
||||||
|
"competing_live_locks": [],
|
||||||
|
"candidate_branches": [BRANCH],
|
||||||
|
"current_pid": os.getpid(),
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
make_lock() if lock is None else lock, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeadSessionRecoveryGranted(unittest.TestCase):
|
||||||
|
def test_dead_pid_with_exact_evidence_recovers(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED)
|
||||||
|
|
||||||
|
def test_recovery_still_granted_when_no_open_pr_exists(self):
|
||||||
|
# A locked branch need not have a PR yet; absence must not block.
|
||||||
|
result = assess(pr_head_sha=None, pr_number=None)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
|
||||||
|
def test_lease_expiry_is_not_required_for_recovery(self):
|
||||||
|
# The defining condition is PID death, not TTL expiry (the #601 gap).
|
||||||
|
lock = make_lock()
|
||||||
|
self.assertFalse(issue_lock_store.is_lease_expired(lock))
|
||||||
|
self.assertFalse(issue_lock_store.assess_lock_freshness(lock)["live"])
|
||||||
|
self.assertTrue(assess(lock)["recovery_sanctioned"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeadSessionRecoveryRefused(unittest.TestCase):
|
||||||
|
def assert_refused(self, result, needle):
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.REFUSED)
|
||||||
|
self.assertTrue(
|
||||||
|
any(needle in reason for reason in result["reasons"]),
|
||||||
|
f"expected {needle!r} in {result['reasons']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_prior_pid_refused(self):
|
||||||
|
lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
|
||||||
|
# Distinct current pid so the refusal is attributable to liveness.
|
||||||
|
self.assert_refused(assess(lock, current_pid=os.getpid() + 1), "still alive")
|
||||||
|
|
||||||
|
def test_different_author_identity_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(identity="someone-else"), "does not match active identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_profile_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(profile="other-profile"), "does not match active profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_branch_refused(self):
|
||||||
|
lock = make_lock(branch_name=f"fix/issue-{ISSUE}-other")
|
||||||
|
self.assert_refused(assess(lock), "does not match requested")
|
||||||
|
|
||||||
|
def test_worktree_parked_on_another_branch_refused(self):
|
||||||
|
self.assert_refused(assess(current_branch="master"), "not the locked branch")
|
||||||
|
|
||||||
|
def test_detached_head_worktree_refused(self):
|
||||||
|
self.assert_refused(assess(current_branch=None), "detached HEAD")
|
||||||
|
|
||||||
|
def test_different_worktree_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(worktree_path="/scratch/elsewhere"), "does not match declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dirty_worktree_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(porcelain_status=" M gitea_mcp_server.py\n"), "requires a clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_head_differing_from_remote_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(remote_head_sha=OTHER_SHA), "does not match remote branch head"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pr_head_differing_refused(self):
|
||||||
|
self.assert_refused(assess(pr_head_sha=OTHER_SHA), "does not match local head")
|
||||||
|
|
||||||
|
def test_missing_remote_head_refused(self):
|
||||||
|
self.assert_refused(assess(remote_head_sha=None), "remote head")
|
||||||
|
|
||||||
|
def test_competing_live_lock_refused(self):
|
||||||
|
competing = [
|
||||||
|
{
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": "/scratch/other-wt",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.assert_refused(
|
||||||
|
assess(competing_live_locks=competing), "competing live lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unrelated_live_lock_does_not_block(self):
|
||||||
|
unrelated = [
|
||||||
|
{
|
||||||
|
"issue_number": 999,
|
||||||
|
"branch_name": "fix/issue-999-unrelated",
|
||||||
|
"worktree_path": "/scratch/unrelated",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.assertTrue(assess(competing_live_locks=unrelated)["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_multiple_candidate_branches_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"]),
|
||||||
|
"multiple branches claim this issue",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_scope_mismatch_refused(self):
|
||||||
|
self.assert_refused(assess(repo="OtherRepo"), "does not match requested")
|
||||||
|
|
||||||
|
def test_malformed_lock_missing_worktree_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock.pop("worktree_path")
|
||||||
|
self.assert_refused(assess(lock), "incomplete")
|
||||||
|
|
||||||
|
def test_malformed_lock_missing_pid_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock.pop("session_pid", None)
|
||||||
|
lock.pop("pid", None)
|
||||||
|
self.assert_refused(assess(lock), "incomplete")
|
||||||
|
|
||||||
|
def test_lock_without_claimant_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock["work_lease"] = dict(lock["work_lease"])
|
||||||
|
lock["work_lease"].pop("claimant")
|
||||||
|
self.assert_refused(assess(lock), "claimant identity/profile")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotACandidate(unittest.TestCase):
|
||||||
|
def test_absent_lock_is_not_a_candidate(self):
|
||||||
|
result = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
None,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
remote="prgs",
|
||||||
|
org="ExampleOrg",
|
||||||
|
repo="ExampleRepo",
|
||||||
|
identity=IDENTITY,
|
||||||
|
profile=PROFILE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head_sha=HEAD,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertFalse(result["is_candidate"])
|
||||||
|
|
||||||
|
def test_lock_for_a_different_issue_is_not_a_candidate(self):
|
||||||
|
result = assess(make_lock(issue_number=7777))
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorktreeGateWaiver(unittest.TestCase):
|
||||||
|
def test_new_issue_claim_still_requires_base_equivalence(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["base_equivalence_waived"])
|
||||||
|
|
||||||
|
def test_sanctioned_recovery_waives_base_equivalence(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=False,
|
||||||
|
recovery_sanctioned=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result["reasons"])
|
||||||
|
self.assertTrue(result["base_equivalence_waived"])
|
||||||
|
|
||||||
|
def test_recovery_never_waives_cleanliness(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status=" M gitea_mcp_server.py\n",
|
||||||
|
base_equivalent=False,
|
||||||
|
recovery_sanctioned=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("tracked file edits" in reason for reason in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unproven_base_equivalence_still_blocks_without_recovery(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=None,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryRecordAndDownstream(unittest.TestCase):
|
||||||
|
def test_recovery_record_preserves_truthful_provenance(self):
|
||||||
|
assessment = assess()
|
||||||
|
prior = assessment["evidence"]["prior_session_pid"]
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
self.assertTrue(record["recovered"])
|
||||||
|
self.assertEqual(record["prior_session_pid"], prior)
|
||||||
|
self.assertEqual(record["replacement_session_pid"], os.getpid())
|
||||||
|
self.assertNotEqual(
|
||||||
|
record["prior_session_pid"], record["replacement_session_pid"]
|
||||||
|
)
|
||||||
|
self.assertFalse(record["prior_pid_alive"])
|
||||||
|
self.assertEqual(record["recovered_at"], "2026-07-18T23:21:40Z")
|
||||||
|
self.assertEqual(record["branch_name"], BRANCH)
|
||||||
|
self.assertEqual(record["local_head"], HEAD)
|
||||||
|
self.assertEqual(record["identity"], IDENTITY)
|
||||||
|
self.assertTrue(record["proof"])
|
||||||
|
|
||||||
|
def test_recovery_record_carries_no_secret_material(self):
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assess(), recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
blob = repr(record).lower()
|
||||||
|
for banned in ("token", "password", "authorization", "secret", "api_key"):
|
||||||
|
self.assertNotIn(banned, blob)
|
||||||
|
|
||||||
|
def test_recovered_lock_satisfies_update_by_merge_ownership(self):
|
||||||
|
# After recovery the lock is rebound to the live session, so the
|
||||||
|
# ownership re-check used by gitea_update_pr_branch_by_merge passes.
|
||||||
|
assessment = assess()
|
||||||
|
recovered_lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
|
||||||
|
recovered_lock["dead_session_recovery"] = (
|
||||||
|
issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
freshness = issue_lock_store.assess_lock_freshness(recovered_lock)
|
||||||
|
self.assertTrue(freshness["live"], freshness)
|
||||||
|
|
||||||
|
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||||
|
recovered_lock,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["proven"], verdict["reasons"])
|
||||||
|
self.assertFalse(verdict["block"])
|
||||||
|
|
||||||
|
def test_pre_recovery_lock_fails_ownership_check(self):
|
||||||
|
# Guards against a false positive above: the dead-PID lock must fail.
|
||||||
|
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||||
|
make_lock(),
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["block"])
|
||||||
|
self.assertTrue(any("not live" in reason for reason in verdict["reasons"]))
|
||||||
|
|
||||||
|
def test_no_manual_file_seeding_required(self):
|
||||||
|
# The whole decision is reachable from the durable record plus live
|
||||||
|
# observation; nothing is written to disk to reach a verdict.
|
||||||
|
self.assertTrue(assess()["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_refusal_message_is_fail_closed(self):
|
||||||
|
message = issue_lock_recovery.format_recovery_refusal(
|
||||||
|
assess(porcelain_status=" M x.py\n")
|
||||||
|
)
|
||||||
|
self.assertIn("fail closed", message)
|
||||||
|
self.assertIn("recovery refused", message.lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
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
|
||||||
|
"""Dead-session lock recovery when the issue already owns an open PR (#755).
|
||||||
|
|
||||||
|
#753 added the recovery *assessor*, but the production ``gitea_lock_issue``
|
||||||
|
path still rejected every sanctioned recovery: a dead-session lock is by
|
||||||
|
construction a lock for work that already has an open PR, and the #400
|
||||||
|
duplicate-work gate blocked unconditionally on any linked open PR. These tests
|
||||||
|
drive the real MCP handler, not just the pure assessor, so that gap cannot
|
||||||
|
reopen.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_provenance # noqa: E402
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from issue_work_duplicate_gate import ( # noqa: E402
|
||||||
|
OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||||
|
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
||||||
|
PHASE_LOCK,
|
||||||
|
assess_work_issue_duplicate_gate,
|
||||||
|
)
|
||||||
|
|
||||||
|
ISSUE = 4755
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-owning-pr"
|
||||||
|
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
|
||||||
|
HEAD = "c" * 40
|
||||||
|
OTHER_HEAD = "d" * 40
|
||||||
|
OWNING_PR = 4756
|
||||||
|
OTHER_PR = 4757
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "test-author-prgs"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
|
||||||
|
|
||||||
|
def dead_pid() -> int:
|
||||||
|
"""A PID that has certainly exited (spawned, then reaped)."""
|
||||||
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||||
|
proc.wait()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def shifted_ts(hours: int = 4) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"fix: something (Closes #{issue})",
|
||||||
|
"body": f"Closes #{issue}.",
|
||||||
|
"head": {"ref": ref, "sha": sha},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanctioned_token(
|
||||||
|
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
||||||
|
):
|
||||||
|
"""The evidence shape the server derives from a granted recovery.
|
||||||
|
|
||||||
|
#768 extends the token with recorded/accepted heads and the head relation
|
||||||
|
so a strict-descendant recovery can still exempt the owning PR after the
|
||||||
|
remediation commit lands. Exact-head recovery (#753/#755) reports equal
|
||||||
|
heads under the same shape.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
"recorded_head": head,
|
||||||
|
"accepted_head": head,
|
||||||
|
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── duplicate gate: exemption ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrExemptionGranted(unittest.TestCase):
|
||||||
|
def test_exact_owning_pr_is_not_duplicate_work(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||||
|
self.assertEqual(result["linked_open_pr"], OWNING_PR)
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
||||||
|
|
||||||
|
def test_unrelated_open_pr_alongside_owning_pr_is_ignored(self):
|
||||||
|
unrelated = {
|
||||||
|
"number": 999,
|
||||||
|
"title": "chore: unrelated",
|
||||||
|
"body": "no linkage",
|
||||||
|
"head": {"ref": "chore/unrelated", "sha": OTHER_HEAD},
|
||||||
|
}
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[unrelated, owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrExemptionRefused(unittest.TestCase):
|
||||||
|
def assert_blocked(self, result):
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
|
||||||
|
|
||||||
|
def test_no_recovery_evidence_keeps_ordinary_blocker(self):
|
||||||
|
self.assert_blocked(
|
||||||
|
assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_competing_pr_number_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(number=OTHER_PR)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_multiple_linked_open_prs_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 2)
|
||||||
|
|
||||||
|
def test_different_branch_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_locked_branch_differing_from_evidence_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=OTHER_BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_different_head_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(sha=OTHER_HEAD)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_evidence_for_another_issue_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(issue_number=ISSUE + 1),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_missing_head_in_live_pr_refused(self):
|
||||||
|
pr = owning_pr()
|
||||||
|
pr["head"] = {"ref": BRANCH}
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[pr],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrdinaryDuplicateBehaviorUnchanged(unittest.TestCase):
|
||||||
|
def test_clean_issue_still_passes(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[],
|
||||||
|
branch_names=["feat/other-issue-99"],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_competing_branch_still_blocks_even_with_owning_pr_evidence(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH, OTHER_BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
# The owning PR is exempt, but the competing branch is not.
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertIn(OTHER_BRANCH, result["conflicting_branches"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────── server-derived evidence cannot be forged ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrEvidenceDerivation(unittest.TestCase):
|
||||||
|
def granted(self, **evidence_overrides):
|
||||||
|
evidence = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"locked_branch": BRANCH,
|
||||||
|
"local_head": HEAD,
|
||||||
|
"remote_head": HEAD,
|
||||||
|
"pr_head": HEAD,
|
||||||
|
"pr_number": OWNING_PR,
|
||||||
|
}
|
||||||
|
evidence.update(evidence_overrides)
|
||||||
|
return {
|
||||||
|
"outcome": issue_lock_recovery.RECOVERY_SANCTIONED,
|
||||||
|
"recovery_sanctioned": True,
|
||||||
|
"is_candidate": True,
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_granted_recovery_yields_evidence(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(self.granted())
|
||||||
|
self.assertEqual(token, sanctioned_token())
|
||||||
|
|
||||||
|
def test_none_assessment_yields_nothing(self):
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(None))
|
||||||
|
|
||||||
|
def test_refused_assessment_yields_nothing(self):
|
||||||
|
refused = self.granted()
|
||||||
|
refused["outcome"] = issue_lock_recovery.REFUSED
|
||||||
|
refused["recovery_sanctioned"] = False
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(refused))
|
||||||
|
|
||||||
|
def test_sanctioned_flag_without_outcome_yields_nothing(self):
|
||||||
|
forged = self.granted()
|
||||||
|
forged["outcome"] = "SOMETHING_ELSE"
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(forged))
|
||||||
|
|
||||||
|
def test_missing_pr_number_yields_nothing(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(pr_number=None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_head_disagreement_in_evidence_yields_nothing(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(remote_head=OTHER_HEAD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(local_head=OTHER_HEAD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_real_refused_assessment_yields_nothing(self):
|
||||||
|
"""End-to-end against the real assessor, not a hand-built dict."""
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": "/scratch/wt",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
"session_pid": os.getpid(), # alive → must refuse
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": "/scratch/wt",
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": shifted_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
lock,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path="/scratch/wt",
|
||||||
|
remote="prgs",
|
||||||
|
org="Example-Org",
|
||||||
|
repo="Example-Repo",
|
||||||
|
identity=IDENTITY,
|
||||||
|
profile=PROFILE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head_sha=HEAD,
|
||||||
|
pr_head_sha=HEAD,
|
||||||
|
pr_number=OWNING_PR,
|
||||||
|
competing_live_locks=[],
|
||||||
|
candidate_branches=[BRANCH],
|
||||||
|
current_pid=os.getpid(),
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["recovery_sanctioned"])
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(assessment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────── end-to-end: the real gitea_lock_issue ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class LockIssueEndToEndBase(unittest.TestCase):
|
||||||
|
"""Drives ``mcp_server.gitea_lock_issue`` with live git/Gitea observation
|
||||||
|
stubbed at the module boundary — the production gate chain itself runs."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.lock_dir.cleanup)
|
||||||
|
self.worktree = os.path.realpath(os.getcwd())
|
||||||
|
# Bind host/org/repo to what the ``test-author-prgs`` fixture profile is
|
||||||
|
# pinned to, so the session-context gate under test is the real one and
|
||||||
|
# not a cross-host denial. The issue number and lock dir stay synthetic.
|
||||||
|
self.remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self.remotes.start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
|
||||||
|
def write_durable_lock(self, *, pid, branch=BRANCH, worktree=None):
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
claimant = {"username": IDENTITY, "profile": PROFILE}
|
||||||
|
data = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": branch,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
"worktree_path": worktree or self.worktree,
|
||||||
|
"session_pid": pid,
|
||||||
|
"pid": pid,
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": branch,
|
||||||
|
"worktree_path": worktree or self.worktree,
|
||||||
|
"claimant": claimant,
|
||||||
|
"created_at": shifted_ts(-1),
|
||||||
|
"last_heartbeat_at": shifted_ts(-1),
|
||||||
|
"expires_at": shifted_ts(),
|
||||||
|
},
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue",
|
||||||
|
claimant=claimant,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
issue_lock_store.save_lock_file(path, data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def run_lock(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
open_prs,
|
||||||
|
porcelain="",
|
||||||
|
current_branch=BRANCH,
|
||||||
|
base_equivalent=False,
|
||||||
|
branch_names=None,
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head=HEAD,
|
||||||
|
):
|
||||||
|
branch_names = branch_names if branch_names is not None else [BRANCH]
|
||||||
|
branch_entries = [
|
||||||
|
{"name": name, "commit": {"id": remote_head}} for name in branch_names
|
||||||
|
]
|
||||||
|
env = shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"mcp_server.api_get_all", return_value=branch_entries
|
||||||
|
), patch(
|
||||||
|
"mcp_server._list_open_pulls", return_value=list(open_prs)
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._work_lease_claimant",
|
||||||
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||||
|
), patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value={
|
||||||
|
"current_branch": current_branch,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
"base_equivalent": base_equivalent,
|
||||||
|
"head_sha": head_sha,
|
||||||
|
"inspected_git_root": self.worktree,
|
||||||
|
"base_branch": "master",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
side_effect=lambda h, o, r, auth, issue_number: (
|
||||||
|
list(open_prs), list(branch_names), {"status": "not_claimed"}
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
return mcp_server.gitea_lock_issue(
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=self.worktree,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryWithOwningPrSucceeds(LockIssueEndToEndBase):
|
||||||
|
def test_dead_session_recovery_with_owning_pr_relocks(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
result = self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["issue_number"], ISSUE)
|
||||||
|
self.assertEqual(result["branch_name"], BRANCH)
|
||||||
|
self.assertTrue(result["lock_freshness"]["live"])
|
||||||
|
self.assertTrue(result["lock_freshness"]["pid_alive"])
|
||||||
|
|
||||||
|
def test_recovered_lock_records_truthful_provenance(self):
|
||||||
|
prior = dead_pid()
|
||||||
|
self.write_durable_lock(pid=prior)
|
||||||
|
self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
lock = issue_lock_store.load_issue_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
record = lock.get("dead_session_recovery") or {}
|
||||||
|
self.assertTrue(record.get("recovered"))
|
||||||
|
self.assertEqual(record.get("prior_session_pid"), prior)
|
||||||
|
self.assertEqual(record.get("replacement_session_pid"), os.getpid())
|
||||||
|
self.assertFalse(record.get("prior_pid_alive"))
|
||||||
|
self.assertEqual(record.get("pr_number"), OWNING_PR)
|
||||||
|
self.assertEqual(record.get("branch_name"), BRANCH)
|
||||||
|
self.assertEqual(record.get("identity"), IDENTITY)
|
||||||
|
|
||||||
|
def test_recovered_lock_is_live_and_proves_pr_ownership(self):
|
||||||
|
"""AC6: the persisted lock satisfies update-by-merge's ownership prover."""
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
lock = issue_lock_store.load_issue_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
self.assertTrue(issue_lock_store.is_lease_live(lock))
|
||||||
|
|
||||||
|
env = shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
ownership = mcp_server._prove_author_ownership_for_pr(
|
||||||
|
pr_number=OWNING_PR,
|
||||||
|
pr_title=f"fix: something (Closes #{ISSUE})",
|
||||||
|
pr_body=f"Closes #{ISSUE}.",
|
||||||
|
source_branch=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
worktree_path=self.worktree,
|
||||||
|
)
|
||||||
|
self.assertTrue(ownership["proven"], ownership["reasons"])
|
||||||
|
self.assertTrue(ownership["has_author_lock"])
|
||||||
|
self.assertEqual(ownership["matched_issue"], ISSUE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryRejectionsEndToEnd(LockIssueEndToEndBase):
|
||||||
|
def assert_lock_refused(self, **kwargs):
|
||||||
|
with self.assertRaises((ValueError, RuntimeError)) as ctx:
|
||||||
|
self.run_lock(**kwargs)
|
||||||
|
return str(ctx.exception)
|
||||||
|
|
||||||
|
def test_competing_pr_still_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
message = self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
)
|
||||||
|
self.assertIn("already covers issue", message)
|
||||||
|
|
||||||
|
def test_multiple_linked_open_prs_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
message = self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
)
|
||||||
|
self.assertIn("already covers issue", message)
|
||||||
|
|
||||||
|
def test_owning_pr_on_a_different_head_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr(sha=OTHER_HEAD)])
|
||||||
|
|
||||||
|
def test_lock_registered_to_a_different_worktree_blocked(self):
|
||||||
|
self.write_durable_lock(
|
||||||
|
pid=dead_pid(), worktree=os.path.join(self.worktree, "elsewhere")
|
||||||
|
)
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
def test_dirty_worktree_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], porcelain=" M gitea_mcp_server.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_worktree_parked_on_another_branch_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], current_branch="master"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_head_differing_from_remote_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], head_sha=OTHER_HEAD
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_prior_pid_blocked(self):
|
||||||
|
self.write_durable_lock(pid=os.getpid())
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
def test_new_claim_without_prior_lock_still_requires_base_equivalence(self):
|
||||||
|
"""AC10: no durable lock → no recovery → base-equivalence still rules."""
|
||||||
|
self.assert_lock_refused(open_prs=[], branch_names=[])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
"""Regression tests for #757: #274 and #604 must not disagree.
|
||||||
|
|
||||||
|
The sanctioned ``create_issue`` bootstrap (#749/#750) was unreachable in
|
||||||
|
production: the #274 branches-only guard consulted the bootstrap and permitted
|
||||||
|
a clean canonical control checkout, then the bootstrap-blind #604 anti-stomp
|
||||||
|
preflight rejected the same checkout as ``wrong_worktree``.
|
||||||
|
|
||||||
|
These tests pin the fix:
|
||||||
|
|
||||||
|
* one server-derived assessment, interpreted by one shared predicate;
|
||||||
|
* the waiver is narrow (only the wrong-worktree verdict, only create_issue,
|
||||||
|
only from the exact clean canonical control checkout);
|
||||||
|
* every other guard and rejection reason keeps its fail-closed behaviour;
|
||||||
|
* eligibility cannot be forged through a public tool signature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import anti_stomp_preflight as asp # noqa: E402
|
||||||
|
import create_issue_bootstrap as cib # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # 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])
|
||||||
|
|
||||||
|
BRANCHES_WORKTREE = str(Path(CONTROL_CHECKOUT_ROOT) / "branches" / "issue-1-x")
|
||||||
|
|
||||||
|
|
||||||
|
def proven_bootstrap(
|
||||||
|
*,
|
||||||
|
workspace=CONTROL_CHECKOUT_ROOT,
|
||||||
|
root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
task="create_issue",
|
||||||
|
branch="master",
|
||||||
|
porcelain="",
|
||||||
|
head=MASTER_SHA,
|
||||||
|
remote_sha=MASTER_SHA,
|
||||||
|
):
|
||||||
|
"""Build a real assessment via the production assessor (never hand-rolled)."""
|
||||||
|
return cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=root,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=remote_sha,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedPredicate(unittest.TestCase):
|
||||||
|
"""The one interpretation both guards consume (AC6, AC7)."""
|
||||||
|
|
||||||
|
def _permits(self, assessment, task="create_issue", workspace=None, root=None):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task=task,
|
||||||
|
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=root or CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proven_bootstrap_permits(self):
|
||||||
|
self.assertTrue(self._permits(proven_bootstrap()))
|
||||||
|
|
||||||
|
def test_tool_alias_permits(self):
|
||||||
|
assessment = proven_bootstrap(task="gitea_create_issue")
|
||||||
|
self.assertTrue(self._permits(assessment, task="gitea_create_issue"))
|
||||||
|
|
||||||
|
def test_missing_assessment_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(None))
|
||||||
|
|
||||||
|
def test_malformed_assessment_fails_closed(self):
|
||||||
|
for bogus in ("allowed", 1, True, [], ["allowed"], object()):
|
||||||
|
with self.subTest(bogus=bogus):
|
||||||
|
self.assertFalse(self._permits(bogus))
|
||||||
|
|
||||||
|
def test_refused_assessment_fails_closed(self):
|
||||||
|
# Dirty control checkout -> assessor blocks -> predicate must refuse.
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(proven_bootstrap(porcelain=" M gitea_mcp_server.py\n"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_not_applicable_assessment_fails_closed(self):
|
||||||
|
# branches/ worktree -> not_applicable -> no waiver.
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(proven_bootstrap(workspace=BRANCHES_WORKTREE))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_assessment_fails_closed(self):
|
||||||
|
incomplete = dict(proven_bootstrap())
|
||||||
|
del incomplete["bootstrap_path"]
|
||||||
|
self.assertFalse(self._permits(incomplete))
|
||||||
|
|
||||||
|
def test_contradictory_block_and_allow_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), block=True)
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_dirty_but_allowed_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), dirty_files=["gitea_mcp_server.py"])
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_under_branches_but_allowed_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), under_branches=True)
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_reasons_present_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), reasons=["something refused"])
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_truthy_non_true_values_fail_closed(self):
|
||||||
|
"""Strict identity: no truthy smuggling (1, 'yes') can assert eligibility."""
|
||||||
|
for value in (1, "yes", "true", [1]):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertFalse(self._permits(dict(proven_bootstrap(), allowed=value)))
|
||||||
|
|
||||||
|
def test_wrong_task_fails_closed(self):
|
||||||
|
"""An otherwise-proven assessment cannot license a different task."""
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task="lock_issue"))
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task="create_pr"))
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task=None))
|
||||||
|
|
||||||
|
def test_foreign_workspace_binding_fails_closed(self):
|
||||||
|
"""Assessment must describe the workspace actually being guarded."""
|
||||||
|
other = proven_bootstrap(workspace="/other/clone", root="/other/clone")
|
||||||
|
self.assertFalse(self._permits(other))
|
||||||
|
|
||||||
|
def test_scope_and_path_tampering_fails_closed(self):
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(dict(proven_bootstrap(), task_scope="all_tasks"))
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(dict(proven_bootstrap(), bootstrap_path="anything_goes"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiStompHonorsBootstrap(unittest.TestCase):
|
||||||
|
"""#604 consumes the same decision, and waives only wrong_worktree."""
|
||||||
|
|
||||||
|
def _assess(self, *, task="create_issue", bootstrap=None, workspace=None, **kw):
|
||||||
|
params = dict(
|
||||||
|
task=task,
|
||||||
|
profile_role="author",
|
||||||
|
required_role="author",
|
||||||
|
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
|
||||||
|
project_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
root_head_sha=MASTER_SHA,
|
||||||
|
root_porcelain="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
check_repo=False,
|
||||||
|
create_issue_bootstrap_assessment=bootstrap,
|
||||||
|
)
|
||||||
|
params.update(kw)
|
||||||
|
return asp.assess_anti_stomp_preflight(**params)
|
||||||
|
|
||||||
|
def _blocker_kinds(self, result):
|
||||||
|
return {b["kind"] for b in result.get("blockers") or []}
|
||||||
|
|
||||||
|
def test_control_checkout_without_bootstrap_still_blocked(self):
|
||||||
|
"""Baseline: the exact production failure, unwaived."""
|
||||||
|
res = self._assess(bootstrap=None)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_control_checkout_with_proven_bootstrap_permitted(self):
|
||||||
|
"""AC1/AC2: the #757 fix — same inputs, bootstrap honoured."""
|
||||||
|
res = self._assess(bootstrap=proven_bootstrap())
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertTrue(res["checks"]["worktree"]["create_issue_bootstrap_waived"])
|
||||||
|
self.assertFalse(res["checks"]["worktree"]["block"])
|
||||||
|
|
||||||
|
def test_tool_alias_permitted(self):
|
||||||
|
res = self._assess(
|
||||||
|
task="gitea_create_issue",
|
||||||
|
bootstrap=proven_bootstrap(task="gitea_create_issue"),
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_non_create_issue_task_never_waived(self):
|
||||||
|
"""AC5: other author mutations keep the branches-only requirement."""
|
||||||
|
for task in ("lock_issue", "create_pr", "commit_files", "mark_issue"):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
res = self._assess(task=task, bootstrap=proven_bootstrap())
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_still_blocked(self):
|
||||||
|
"""AC4: dirty root refuses the bootstrap, so no waiver."""
|
||||||
|
dirty = " M gitea_mcp_server.py\n"
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(porcelain=dirty), root_porcelain=dirty
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_detached_control_checkout_still_blocked(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(branch=""), current_branch=""
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_base_divergence_still_blocked(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(head=STALE_SHA), root_head_sha=STALE_SHA
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_stale_runtime(self):
|
||||||
|
"""AC: waive only wrong_worktree — stale runtime still fails closed."""
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
startup_head=STALE_SHA,
|
||||||
|
current_code_head=MASTER_SHA,
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertIn(asp.BLOCKER_STALE_RUNTIME, self._blocker_kinds(res))
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_wrong_repo(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
check_repo=True,
|
||||||
|
remote="prgs",
|
||||||
|
resolved_org="Scaled-Tech-Consulting",
|
||||||
|
resolved_repo="Timesheet",
|
||||||
|
# Not both-explicit, so the #530 repo guard actually evaluates the
|
||||||
|
# mismatch against the local remote instead of trusting the caller.
|
||||||
|
org_explicit=False,
|
||||||
|
repo_explicit=False,
|
||||||
|
local_remote_url=(
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_REPO, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_wrong_role(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
profile_role="reviewer",
|
||||||
|
required_role="author",
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_ROLE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_branches_worktree_unaffected(self):
|
||||||
|
"""AC: ordinary branches/ worktrees keep working, waived or not."""
|
||||||
|
for bootstrap in (None, proven_bootstrap(workspace=BRANCHES_WORKTREE)):
|
||||||
|
with self.subTest(bootstrap=bool(bootstrap)):
|
||||||
|
res = self._assess(
|
||||||
|
workspace=BRANCHES_WORKTREE,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
current_branch="fix/issue-1-x",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forged_assessment_rejected(self):
|
||||||
|
"""AC6: a hand-built 'allowed' dict cannot unlock the waiver."""
|
||||||
|
forged = {"allowed": True, "block": False}
|
||||||
|
res = self._assess(bootstrap=forged)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuardAgreement(unittest.TestCase):
|
||||||
|
"""AC7: the regression that would have caught the #757 defect.
|
||||||
|
|
||||||
|
For identical evidence, the #274 guard and the #604 guard must return the
|
||||||
|
same wrong-worktree verdict across the full workspace-state matrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MATRIX = [
|
||||||
|
(
|
||||||
|
"clean control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + alias",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "gitea_create_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + lock_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "lock_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + create_pr",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_pr", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dirty control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master",
|
||||||
|
" M gitea_mcp_server.py\n", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"detached control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"non-base control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "feat/x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"diverged control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", STALE_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"branches wt + create_issue",
|
||||||
|
BRANCHES_WORKTREE, "create_issue", "fix/issue-1-x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"branches wt + lock_issue",
|
||||||
|
BRANCHES_WORKTREE, "lock_issue", "fix/issue-1-x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _guard_274_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
|
||||||
|
git_state = {
|
||||||
|
"current_branch": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
}
|
||||||
|
ctx = {
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
|
||||||
|
}
|
||||||
|
with patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv, "_resolve_namespace_mutation_context", return_value=ctx
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=git_state,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
srv._enforce_branches_only_author_mutation(
|
||||||
|
workspace, task=task, bootstrap_assessment=bootstrap
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except RuntimeError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _guard_604_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
|
||||||
|
res = asp.assess_anti_stomp_preflight(
|
||||||
|
task=task,
|
||||||
|
profile_role="author",
|
||||||
|
required_role="author",
|
||||||
|
workspace_path=workspace,
|
||||||
|
project_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch=branch,
|
||||||
|
root_head_sha=head,
|
||||||
|
root_porcelain=porcelain,
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
check_repo=False,
|
||||||
|
create_issue_bootstrap_assessment=bootstrap,
|
||||||
|
)
|
||||||
|
return asp.BLOCKER_WRONG_WORKTREE in {
|
||||||
|
b["kind"] for b in res.get("blockers") or []
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_guards_agree_across_matrix(self):
|
||||||
|
for label, workspace, task, branch, porcelain, head in self.MATRIX:
|
||||||
|
with self.subTest(case=label):
|
||||||
|
# ONE server-derived assessment, exactly as production computes it.
|
||||||
|
bootstrap = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
kwargs = dict(
|
||||||
|
workspace=workspace,
|
||||||
|
task=task,
|
||||||
|
branch=branch,
|
||||||
|
porcelain=porcelain,
|
||||||
|
head=head,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
)
|
||||||
|
blocked_274 = self._guard_274_blocks(**kwargs)
|
||||||
|
blocked_604 = self._guard_604_blocks(**kwargs)
|
||||||
|
self.assertEqual(
|
||||||
|
blocked_274,
|
||||||
|
blocked_604,
|
||||||
|
f"{label}: #274 blocked={blocked_274} "
|
||||||
|
f"but #604 blocked={blocked_604}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_control_create_issue_permitted_by_both(self):
|
||||||
|
"""The specific case that was broken: both guards must permit."""
|
||||||
|
kwargs = dict(
|
||||||
|
workspace=CONTROL_CHECKOUT_ROOT,
|
||||||
|
task="create_issue",
|
||||||
|
branch="master",
|
||||||
|
porcelain="",
|
||||||
|
head=MASTER_SHA,
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
)
|
||||||
|
self.assertFalse(self._guard_274_blocks(**kwargs))
|
||||||
|
self.assertFalse(self._guard_604_blocks(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoCallerForgeableEligibility(unittest.TestCase):
|
||||||
|
"""AC6: eligibility is never reachable through a public tool signature."""
|
||||||
|
|
||||||
|
def test_create_issue_tool_exposes_no_bootstrap_argument(self):
|
||||||
|
params = set(inspect.signature(srv.gitea_create_issue).parameters)
|
||||||
|
for forbidden in (
|
||||||
|
"bootstrap",
|
||||||
|
"bootstrap_assessment",
|
||||||
|
"create_issue_bootstrap",
|
||||||
|
"create_issue_bootstrap_assessment",
|
||||||
|
"allow_control_checkout",
|
||||||
|
"bootstrap_allowed",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, params)
|
||||||
|
|
||||||
|
def test_no_mcp_tool_exposes_bootstrap_argument(self):
|
||||||
|
"""No public gitea_* tool may take bootstrap evidence from the caller."""
|
||||||
|
for name in dir(srv):
|
||||||
|
if not name.startswith("gitea_"):
|
||||||
|
continue
|
||||||
|
fn = getattr(srv, name)
|
||||||
|
if not callable(fn):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
params = set(inspect.signature(fn).parameters)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
leaked = {p for p in params if "bootstrap" in p.lower()}
|
||||||
|
self.assertFalse(leaked, f"{name} exposes bootstrap args: {leaked}")
|
||||||
|
|
||||||
|
def test_internal_helper_yields_nothing_for_other_tasks(self):
|
||||||
|
self.assertTrue(hasattr(srv, "_create_issue_bootstrap_assessment"))
|
||||||
|
self.assertIsNone(
|
||||||
|
srv._create_issue_bootstrap_assessment("lock_issue"),
|
||||||
|
"non-create_issue tasks must yield no bootstrap evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNativeCreateIssueEndToEnd(unittest.TestCase):
|
||||||
|
"""AC8: the production handler, with the #604 gate LIVE (not patched out).
|
||||||
|
|
||||||
|
The pre-existing #749 e2e test patched ``_run_anti_stomp_preflight`` to a
|
||||||
|
no-op, which is exactly why this defect reached production. These tests
|
||||||
|
leave it running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_create_issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
git_state,
|
||||||
|
remote_sha=MASTER_SHA,
|
||||||
|
parity=None,
|
||||||
|
title="Bootstrap issue from clean control",
|
||||||
|
):
|
||||||
|
"""Invoke the native handler with the anti-stomp gate live."""
|
||||||
|
parity = parity or {
|
||||||
|
"startup_head": MASTER_SHA,
|
||||||
|
"current_head": MASTER_SHA,
|
||||||
|
}
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(srv, "_profile_permission_block", return_value=None), \
|
||||||
|
patch.object(srv, "_namespace_mutation_block", return_value=None), \
|
||||||
|
patch.object(
|
||||||
|
srv.role_session_router,
|
||||||
|
"check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "api_get_all", return_value=[]), \
|
||||||
|
patch.object(srv, "api_request") as mock_api, \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
return_value=remote_sha,
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_current_master_parity", return_value=parity), \
|
||||||
|
patch.object(
|
||||||
|
srv,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=git_state,
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv,
|
||||||
|
"_get_workspace_porcelain",
|
||||||
|
return_value=git_state["porcelain_status"],
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"number": 99,
|
||||||
|
"html_url": "https://gitea.example.com/issues/99",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = srv.gitea_create_issue(
|
||||||
|
title=title,
|
||||||
|
body="Body text for content gate.",
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"raised": str(exc)}, mock_api
|
||||||
|
return result, mock_api
|
||||||
|
|
||||||
|
def test_clean_control_checkout_reaches_api(self):
|
||||||
|
"""The exact production failure that blocked filing #757 and #758."""
|
||||||
|
res, mock_api = self._run_create_issue(git_state=self._git_state())
|
||||||
|
self.assertNotIn("raised", res, f"guard still blocks: {res.get('raised')}")
|
||||||
|
self.assertEqual(res.get("number"), 99)
|
||||||
|
mock_api.assert_called_once()
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_blocked(self):
|
||||||
|
dirty = " M gitea_mcp_server.py\n"
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(porcelain=dirty)
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_detached_control_checkout_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(git_state=self._git_state(branch=""))
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_base_mismatch_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(head=STALE_SHA)
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_stale_runtime_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(),
|
||||||
|
parity={"startup_head": STALE_SHA, "current_head": MASTER_SHA},
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_non_create_issue_mutation_still_blocked_from_control(self):
|
||||||
|
"""AC5 through the real preflight, with anti-stomp live."""
|
||||||
|
srv._preflight_resolved_task = "lock_issue"
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
return_value=MASTER_SHA,
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
|
||||||
|
self.assertIn("control checkout", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseEquivalenceProofRequired(unittest.TestCase):
|
||||||
|
"""#757 AC3/AC4: an unknown tip is not evidence of agreement.
|
||||||
|
|
||||||
|
The original fix compared SHAs only inside
|
||||||
|
``if remote_tip and local_tip and remote_tip != local_tip``, so a missing
|
||||||
|
local HEAD, an unresolvable live master, or a resolver failure all fell
|
||||||
|
through and *granted* the bootstrap exemption. Base equivalence must be
|
||||||
|
proven, and anything less must fail closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _permits(self, assessment):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _assert_fails_closed(self, assessment, *, expect_reason):
|
||||||
|
self.assertTrue(assessment["block"], "assessor must block")
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["base_tips_verified"])
|
||||||
|
self.assertFalse(self._permits(assessment), "predicate must refuse")
|
||||||
|
joined = " ".join(assessment["reasons"]).lower()
|
||||||
|
self.assertIn(expect_reason, joined)
|
||||||
|
|
||||||
|
def test_missing_local_head_fails_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(head=None),
|
||||||
|
expect_reason="control checkout head sha is unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_remote_master_fails_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(remote_sha=None),
|
||||||
|
expect_reason="live master tip is unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_both_tips_missing_fails_closed(self):
|
||||||
|
assessment = proven_bootstrap(head=None, remote_sha=None)
|
||||||
|
self._assert_fails_closed(
|
||||||
|
assessment, expect_reason="control checkout head sha is unknown"
|
||||||
|
)
|
||||||
|
self.assertIn("live master tip is unknown", " ".join(assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_empty_and_whitespace_tips_fail_closed(self):
|
||||||
|
for head, remote in (("", MASTER_SHA), (MASTER_SHA, ""), (" ", " ")):
|
||||||
|
with self.subTest(head=repr(head), remote=repr(remote)):
|
||||||
|
assessment = proven_bootstrap(head=head, remote_sha=remote)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_mismatched_tips_fail_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(head=STALE_SHA),
|
||||||
|
expect_reason="does not match",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolver_failure_fails_closed(self):
|
||||||
|
assessment = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=None,
|
||||||
|
remote_master_sha_error="TimeoutError: remote unreachable",
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self._assert_fails_closed(
|
||||||
|
assessment, expect_reason="could not be resolved"
|
||||||
|
)
|
||||||
|
# The operator must be able to see *why*, not just that it blocked.
|
||||||
|
self.assertIn("remote unreachable", " ".join(assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_proven_bootstrap_records_both_normalized_shas(self):
|
||||||
|
assessment = proven_bootstrap()
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
|
||||||
|
self.assertEqual(assessment["remote_master_sha"], MASTER_SHA)
|
||||||
|
self.assertTrue(assessment["base_tips_verified"])
|
||||||
|
self.assertTrue(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_tips_are_normalized_before_comparison(self):
|
||||||
|
"""Case and surrounding whitespace are not a different commit."""
|
||||||
|
assessment = proven_bootstrap(
|
||||||
|
head=f" {MASTER_SHA.upper()} ", remote_sha=MASTER_SHA
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["allowed"])
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
|
||||||
|
self.assertTrue(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_assessment_with_tips_stripped(self):
|
||||||
|
"""A recorded proof that is later removed cannot still permit."""
|
||||||
|
for field in ("local_head_sha", "remote_master_sha"):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment[field] = None
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_forged_verified_flag(self):
|
||||||
|
"""base_tips_verified is re-derived, never trusted on its own."""
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment["local_head_sha"] = MASTER_SHA
|
||||||
|
assessment["remote_master_sha"] = STALE_SHA
|
||||||
|
assessment["base_tips_verified"] = True
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_missing_verified_flag(self):
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment.pop("base_tips_verified")
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerAssessmentFailsClosedOnResolverError(unittest.TestCase):
|
||||||
|
"""AC3/AC4 at the single server-derived computation site."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# verify_preflight_purity short-circuits under pytest; the production
|
||||||
|
# path only runs with test mode disabled (same setup the #757 e2e uses).
|
||||||
|
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._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):
|
||||||
|
return {
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": MASTER_SHA,
|
||||||
|
"porcelain_status": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_resolver_exception_produces_blocking_assessment(self):
|
||||||
|
"""A raising resolver must not become a silent, permissive None."""
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
side_effect=TimeoutError("remote unreachable"),
|
||||||
|
):
|
||||||
|
assessment = srv._create_issue_bootstrap_assessment("create_issue")
|
||||||
|
|
||||||
|
self.assertIsNotNone(assessment)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertFalse(assessment["base_tips_verified"])
|
||||||
|
self.assertIsNone(assessment["remote_master_sha"])
|
||||||
|
self.assertIn("could not be resolved", " ".join(assessment["reasons"]))
|
||||||
|
self.assertFalse(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolver_exception_blocks_real_preflight(self):
|
||||||
|
"""Production path: verify_preflight_purity must fail closed."""
|
||||||
|
srv._preflight_resolved_task = "create_issue"
|
||||||
|
try:
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv, "_effective_workspace_role", return_value="author"
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
side_effect=TimeoutError("remote unreachable"),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv.verify_preflight_purity(remote="prgs", task="create_issue")
|
||||||
|
finally:
|
||||||
|
srv._preflight_resolved_task = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Regression coverage for reviewer-lease preflight ordering (#763)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import anti_stomp_preflight
|
||||||
|
import gitea_mcp_server as server
|
||||||
|
import merger_lease_adoption
|
||||||
|
import reviewer_pr_lease
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
|
||||||
|
def _prime_clean_reviewer_preflight(monkeypatch, resolved_task: str) -> None:
|
||||||
|
"""Install a clean reviewer preflight without bypassing task matching."""
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.delenv("GITEA_TEST_FORCE_DIRTY", raising=False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_in_test_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_violation", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_violation", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_resolved_role", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_resolved_task", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_baseline_porcelain", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_baseline_porcelain", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_violation_files", [])
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_violation_files", [])
|
||||||
|
monkeypatch.setattr(server, "_preflight_reviewer_violation_files", [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": server.PROJECT_ROOT,
|
||||||
|
"canonical_repo_root": server.PROJECT_ROOT,
|
||||||
|
"process_project_root": server.PROJECT_ROOT,
|
||||||
|
"workspace_role_kind": "reviewer",
|
||||||
|
"workspace_binding_source": "test reviewer binding",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_enforce_stable_branch_contamination_gate", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_canonical_repository_root", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_root_checkout_guard", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_branches_only_author_mutation", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_issue_scope_guard", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_create_issue_bootstrap_assessment", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_run_anti_stomp_preflight", lambda *_a, **_k: None)
|
||||||
|
|
||||||
|
server.record_preflight_check("whoami")
|
||||||
|
server.record_preflight_check(
|
||||||
|
"capability", resolved_role="reviewer", resolved_task=resolved_task
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_review_capability_allows_reviewer_lease_acquire(monkeypatch):
|
||||||
|
"""whoami -> resolve(review_pr) -> acquire reviewer lease is canonical."""
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_lease_capability_without_intervening_call_still_succeeds(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_wrong_and_consumed_capability_fail_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "create_issue")
|
||||||
|
with pytest.raises(RuntimeError, match="task mismatch"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
with pytest.raises(RuntimeError, match="has not been resolved"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_intervening_whoami_read_preserves_capability(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
with patch.object(server, "_get_workspace_porcelain", return_value=""):
|
||||||
|
server.record_preflight_check("whoami")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_transition_is_narrow_alias_aware_and_one_way():
|
||||||
|
assert task_capability_map.preflight_task_matches(
|
||||||
|
"review_pr", "gitea_acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
assert task_capability_map.preflight_task_matches(
|
||||||
|
"gitea_acquire_reviewer_pr_lease", "acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"acquire_reviewer_pr_lease", "review_pr"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"review_pr", "acquire_merger_pr_lease"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"merge_pr", "acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_reviewer_workspace_still_fails_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", " M gitea_mcp_server.py\n")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Reviewer role violation"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_reviewer_workspace_still_fails_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": "/outside/review-pr-762",
|
||||||
|
"canonical_repo_root": "/repo",
|
||||||
|
"process_project_root": "/repo",
|
||||||
|
"workspace_role_kind": "reviewer",
|
||||||
|
"workspace_binding_source": "test reviewer binding",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.author_mutation_worktree,
|
||||||
|
"assess_workspace_repo_membership",
|
||||||
|
lambda **_kwargs: {"block": True, "reasons": ["workspace mismatch"]},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.author_mutation_worktree,
|
||||||
|
"format_workspace_repo_membership_error",
|
||||||
|
lambda _assessment: "workspace mismatch (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="workspace mismatch"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_lease_acquire_requires_workflow_load_proof(monkeypatch):
|
||||||
|
sha = "a" * 40
|
||||||
|
monkeypatch.setattr(server, "_anti_stomp_in_test_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"get_profile",
|
||||||
|
lambda: {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_actual_profile_role", lambda: "reviewer")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": "/repo/branches/review-pr-762",
|
||||||
|
"canonical_repo_root": "/repo",
|
||||||
|
"process_project_root": "/repo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
lambda _path: {
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": sha,
|
||||||
|
"porcelain_status": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
lambda _path: sha,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_current_master_parity",
|
||||||
|
lambda: {"startup_head": sha, "current_head": sha},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_local_git_remote_url", lambda _remote: None)
|
||||||
|
monkeypatch.setattr(server, "_load_stable_contamination_marker", lambda _remote: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_review_workflow_load_gate_reasons",
|
||||||
|
lambda: ["canonical review workflow proof missing"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="workflow"):
|
||||||
|
server._run_anti_stomp_preflight(
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/repo/branches/review-pr-762",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_identity_mismatch_invalidates_preflight(monkeypatch):
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_called", True)
|
||||||
|
monkeypatch.setattr(server, "_auth", lambda _host: "redacted")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"api_request",
|
||||||
|
lambda *_args, **_kwargs: {"login": "wrong-reviewer", "id": 7},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"get_profile",
|
||||||
|
lambda: {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
|
||||||
|
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
|
||||||
|
monkeypatch.setattr(server, "_reveal_endpoints", lambda: False)
|
||||||
|
|
||||||
|
result = server.gitea_whoami(remote="prgs")
|
||||||
|
|
||||||
|
assert result["identity_match"] is False
|
||||||
|
assert server._preflight_whoami_called is False
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_denied_reviewer_profile_does_not_leave_capability_proof(monkeypatch):
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "get_profile", lambda: profile)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.gitea_config,
|
||||||
|
"load_config",
|
||||||
|
lambda: {"profiles": {"prgs-author": profile}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.gitea_config, "is_runtime_switching_enabled", lambda: False)
|
||||||
|
monkeypatch.setattr(server, "_authenticated_username", lambda _host: "jcwalker3")
|
||||||
|
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"assess_session_context",
|
||||||
|
lambda **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"assess_identity_match",
|
||||||
|
lambda **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"profile_allowed_for_remote",
|
||||||
|
lambda *_args, **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.role_session_router,
|
||||||
|
"assess_infra_stop",
|
||||||
|
lambda _root: {"infra_stop": False, "infra_stop_reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_check_mcp_runtimes_diagnostics", lambda *_a: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
lambda **_kwargs: {"classification": "unbound"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "record_mutation_authority", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(server, "init_review_decision_lock", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server.capability_stop_terminal, "is_active", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.capability_stop_terminal,
|
||||||
|
"sync_from_capability_result",
|
||||||
|
lambda _result: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
|
assert result["allowed_in_current_session"] is False
|
||||||
|
assert result["required_role_kind"] == "reviewer"
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_and_foreign_lease_protections_remain_enforced():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
head = "a" * 40
|
||||||
|
moved_head = "b" * 40
|
||||||
|
body = reviewer_pr_lease.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=762,
|
||||||
|
issue_number=605,
|
||||||
|
reviewer_identity="other-reviewer",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="foreign-session",
|
||||||
|
worktree="/repo/branches/review-pr-762",
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head=head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="c" * 40,
|
||||||
|
last_activity=now,
|
||||||
|
)
|
||||||
|
comments = [{"id": 10, "author": "other-reviewer", "body": body}]
|
||||||
|
|
||||||
|
acquire = reviewer_pr_lease.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=762,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="my-session",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=605,
|
||||||
|
worktree="/repo/branches/review-pr-762-mine",
|
||||||
|
candidate_head=head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="c" * 40,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
assert acquire["acquire_allowed"] is False
|
||||||
|
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
reviewer_pr_lease.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": 762,
|
||||||
|
"session_id": "foreign-session",
|
||||||
|
"candidate_head": head,
|
||||||
|
"comment_id": 10,
|
||||||
|
},
|
||||||
|
lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||||||
|
source=merger_lease_adoption.SOURCE_ACQUIRE,
|
||||||
|
comment_id=10,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
gate = reviewer_pr_lease.assess_mutation_lease_gate(
|
||||||
|
pr_number=762,
|
||||||
|
comments=comments,
|
||||||
|
reviewer_identity="other-reviewer",
|
||||||
|
session_id="foreign-session",
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha=moved_head,
|
||||||
|
pinned_head_sha=head,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
|
||||||
|
assert gate["block"] is True
|
||||||
|
assert any("head changed" in reason for reason in gate["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_lease_role_gate_is_not_weakened():
|
||||||
|
result = anti_stomp_preflight.assess_anti_stomp_preflight(
|
||||||
|
task="acquire_reviewer_pr_lease",
|
||||||
|
profile_name="prgs-author",
|
||||||
|
profile_role="author",
|
||||||
|
required_role="reviewer",
|
||||||
|
required_permission="gitea.pr.comment",
|
||||||
|
allowed_operations=["gitea.read"],
|
||||||
|
check_repo=False,
|
||||||
|
check_root_checkout=False,
|
||||||
|
check_worktree=False,
|
||||||
|
check_stale_runtime=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["block"] is True
|
||||||
|
assert result["blocker_kind"] == anti_stomp_preflight.BLOCKER_WRONG_ROLE
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Strict-descendant dead-session recovery (#768).
|
||||||
|
|
||||||
|
After a dead author session, a preserved clean remediation commit that strictly
|
||||||
|
descends from the head recorded at lock time must be recoverable so the author
|
||||||
|
can publish. Equality alone is still accepted (#753); every other divergence
|
||||||
|
must keep failing closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import issue_lock_worktree # noqa: E402
|
||||||
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 7680
|
||||||
|
PR_NUMBER = 7681
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-descendant-recovery"
|
||||||
|
WORKTREE = "/scratch/wt-768"
|
||||||
|
RECORDED = "a" * 40
|
||||||
|
DESCENDANT = "c" * 40
|
||||||
|
DIVERGED = "d" * 40
|
||||||
|
BEHIND = "b" * 40
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "example-author"
|
||||||
|
|
||||||
|
|
||||||
|
def dead_pid() -> int:
|
||||||
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||||
|
proc.wait()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def future_ts(hours: int = 4) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_lock(**overrides):
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"session_pid": dead_pid(),
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": future_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lock.update(overrides)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def ancestry_ok(
|
||||||
|
*,
|
||||||
|
ancestor: str = RECORDED,
|
||||||
|
descendant: str = DESCENDANT,
|
||||||
|
is_strict: bool = True,
|
||||||
|
probe_ok: bool = True,
|
||||||
|
ancestor_present: bool = True,
|
||||||
|
reasons: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"ancestor_sha": ancestor,
|
||||||
|
"descendant_sha": descendant,
|
||||||
|
"probe_ok": probe_ok,
|
||||||
|
"ancestor_present": ancestor_present,
|
||||||
|
"descendant_present": True,
|
||||||
|
"is_ancestor": is_strict or ancestor == descendant,
|
||||||
|
"is_strict_descendant": is_strict,
|
||||||
|
"proof": f"git merge-base --is-ancestor {ancestor} {descendant} -> exit 0",
|
||||||
|
"reasons": list(reasons or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess(**overrides):
|
||||||
|
kwargs = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"profile": PROFILE,
|
||||||
|
"current_branch": BRANCH,
|
||||||
|
"porcelain_status": "",
|
||||||
|
"head_sha": RECORDED,
|
||||||
|
"remote_head_sha": RECORDED,
|
||||||
|
"pr_head_sha": RECORDED,
|
||||||
|
"pr_number": PR_NUMBER,
|
||||||
|
"competing_live_locks": [],
|
||||||
|
"candidate_branches": [BRANCH],
|
||||||
|
"current_pid": os.getpid(),
|
||||||
|
"head_ancestry": None,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
lock = kwargs.pop("lock", None)
|
||||||
|
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
make_lock() if lock is None else lock, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExactHeadRecoveryStillSucceeds(unittest.TestCase):
|
||||||
|
def test_equal_heads_still_sanctioned(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(result["evidence"]["accepted_head"], RECORDED)
|
||||||
|
|
||||||
|
def test_exact_match_record_carries_relation(self):
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assess(), recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
self.assertEqual(record["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
|
||||||
|
self.assertEqual(record["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(record["accepted_head"], RECORDED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrictDescendantRecoverySucceeds(unittest.TestCase):
|
||||||
|
def test_clean_strict_descendant_recovers(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(result["evidence"]["accepted_head"], DESCENDANT)
|
||||||
|
self.assertIsNotNone(result["evidence"]["ancestry_proof"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("strictly descends" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pr_still_at_recorded_head_is_ok_for_descendant(self):
|
||||||
|
# Remediation is local only; open PR still points at the recorded head.
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
|
||||||
|
def test_recovery_record_names_both_heads_and_proof(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
result, recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
self.assertEqual(record["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(record["accepted_head"], DESCENDANT)
|
||||||
|
self.assertEqual(
|
||||||
|
record["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
self.assertIn("strictly descends", record["ancestry_proof"] or "")
|
||||||
|
self.assertEqual(record["prior_session_pid"], result["evidence"]["prior_session_pid"])
|
||||||
|
self.assertEqual(record["replacement_session_pid"], os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescendantEvidenceReachesPublicationGates(unittest.TestCase):
|
||||||
|
def _descendant_assessment(self):
|
||||||
|
return assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_owning_pr_evidence_carries_accepted_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(token)
|
||||||
|
assert token is not None
|
||||||
|
self.assertEqual(token["head_sha"], RECORDED)
|
||||||
|
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||||
|
self.assertEqual(token["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(
|
||||||
|
token["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_persisted_lock_rebuilds_owning_pr_evidence(self):
|
||||||
|
assessment = self._descendant_assessment()
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
lock = make_lock(dead_session_recovery=record)
|
||||||
|
token = issue_lock_recovery.recovered_owning_pr_from_lock(lock)
|
||||||
|
self.assertIsNotNone(token)
|
||||||
|
assert token is not None
|
||||||
|
self.assertEqual(token["pr_number"], PR_NUMBER)
|
||||||
|
self.assertEqual(token["head_sha"], RECORDED)
|
||||||
|
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||||
|
|
||||||
|
def test_duplicate_gate_accepts_pr_at_recorded_or_accepted_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
for live_sha in (RECORDED, DESCENDANT):
|
||||||
|
with self.subTest(live_sha=live_sha):
|
||||||
|
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[
|
||||||
|
{
|
||||||
|
"number": PR_NUMBER,
|
||||||
|
"title": f"Closes #{ISSUE}",
|
||||||
|
"body": f"Closes #{ISSUE}",
|
||||||
|
"head": {"ref": BRANCH, "sha": live_sha},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "unclaimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||||
|
recovered_owning_pr=token,
|
||||||
|
)
|
||||||
|
self.assertFalse(gate["block"], gate)
|
||||||
|
self.assertTrue(gate["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_duplicate_gate_still_rejects_foreign_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[
|
||||||
|
{
|
||||||
|
"number": PR_NUMBER,
|
||||||
|
"title": f"Closes #{ISSUE}",
|
||||||
|
"body": f"Closes #{ISSUE}",
|
||||||
|
"head": {"ref": BRANCH, "sha": DIVERGED},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "unclaimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||||
|
recovered_owning_pr=token,
|
||||||
|
)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertFalse(gate["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirtyDescendantRejected(unittest.TestCase):
|
||||||
|
def test_dirty_descendant_refused(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
porcelain_status=" M issue_lock_recovery.py\n",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(any("dirty" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDivergedAndBehindRejected(unittest.TestCase):
|
||||||
|
def test_diverged_head_refused(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DIVERGED,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
ancestor=RECORDED,
|
||||||
|
descendant=DIVERGED,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=[
|
||||||
|
f"local head {DIVERGED} does not descend from recorded head "
|
||||||
|
f"{RECORDED}"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not match remote" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_behind_recorded_refused(self):
|
||||||
|
# merge-base --is-ancestor RECORDED BEHIND is false when BEHIND is ancestor.
|
||||||
|
result = assess(
|
||||||
|
head_sha=BEHIND,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
ancestor=RECORDED,
|
||||||
|
descendant=BEHIND,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=[
|
||||||
|
f"local head {BEHIND} does not descend from recorded head "
|
||||||
|
f"{RECORDED}"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not match remote" in r or "not a strict descendant" in r
|
||||||
|
for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnrelatedAndMalformedAncestryRejected(unittest.TestCase):
|
||||||
|
def test_missing_ancestry_observation_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=None,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("ancestry" in r.lower() for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mismatched_probe_pair_fails_closed(self):
|
||||||
|
# Observation for a different commit pair must not authorize this pair.
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(ancestor=DIVERGED, descendant=DESCENDANT),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("not the heads under assessment" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rewritten_recorded_head_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(ancestor_present=False, is_strict=False),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("no longer reachable" in r or "rewritten" in r
|
||||||
|
for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_probe_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
probe_ok=False,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=["ancestry probe failed with exit 128; ancestry unproven"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_pr_head_not_equal_to_recorded_blocks_descendant(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=DIVERGED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("open PR" in r and "does not match" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveOwnerStillRejected(unittest.TestCase):
|
||||||
|
def test_live_prior_pid_refused_even_with_descendant_proof(self):
|
||||||
|
result = assess(
|
||||||
|
lock=make_lock(session_pid=os.getpid()),
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("still alive" in r or "live" in r.lower() for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnosticsIdentifyDisposition(unittest.TestCase):
|
||||||
|
def test_equal_disposition_named(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_descendant_disposition_named(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejected_divergence_has_no_accepted_relation(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DIVERGED,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
head_ancestry=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||||
|
message = issue_lock_recovery.format_recovery_refusal(result)
|
||||||
|
self.assertIn("fail closed", message)
|
||||||
|
self.assertIn("does not match remote", message)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadHeadAncestryRealGit(unittest.TestCase):
|
||||||
|
def _git(self, repo: str, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", repo, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_repo_with_chain(self) -> tuple[str, str, str, str]:
|
||||||
|
"""Return (repo, parent_sha, child_sha, sibling_sha)."""
|
||||||
|
repo = tempfile.mkdtemp(prefix="issue-768-ancestry-")
|
||||||
|
self._git(repo, "init")
|
||||||
|
self._git(repo, "config", "user.email", "[email protected]")
|
||||||
|
self._git(repo, "config", "user.name", "Test")
|
||||||
|
path = Path(repo) / "f.txt"
|
||||||
|
path.write_text("one\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "parent")
|
||||||
|
parent = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
path.write_text("two\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "child")
|
||||||
|
child = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
# Divergent sibling: branch from parent, then unique commit.
|
||||||
|
self._git(repo, "checkout", "-B", "side", parent)
|
||||||
|
path.write_text("side\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "sibling")
|
||||||
|
sibling = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
self._git(repo, "checkout", "-B", "main", child)
|
||||||
|
return repo, parent, child, sibling
|
||||||
|
|
||||||
|
def test_strict_descendant_observation(self):
|
||||||
|
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=child
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertTrue(obs["ancestor_present"])
|
||||||
|
self.assertTrue(obs["is_ancestor"])
|
||||||
|
self.assertTrue(obs["is_strict_descendant"])
|
||||||
|
self.assertEqual(obs["ancestor_sha"], parent)
|
||||||
|
self.assertEqual(obs["descendant_sha"], child)
|
||||||
|
|
||||||
|
def test_equal_heads_not_strict_descendant(self):
|
||||||
|
repo, parent, _child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=parent
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertTrue(obs["is_ancestor"])
|
||||||
|
self.assertFalse(obs["is_strict_descendant"])
|
||||||
|
|
||||||
|
def test_diverged_not_ancestor(self):
|
||||||
|
repo, _parent, child, sibling = self._init_repo_with_chain()
|
||||||
|
# child and sibling share a parent but neither descends from the other.
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=child, descendant_sha=sibling
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertFalse(obs["is_ancestor"])
|
||||||
|
self.assertFalse(obs["is_strict_descendant"])
|
||||||
|
|
||||||
|
def test_missing_sha_fails_closed(self):
|
||||||
|
repo, _parent, child, _ = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha="0" * 40, descendant_sha=child
|
||||||
|
)
|
||||||
|
self.assertFalse(obs["probe_ok"])
|
||||||
|
self.assertFalse(obs["ancestor_present"])
|
||||||
|
|
||||||
|
def test_end_to_end_real_git_descendant_recovery(self):
|
||||||
|
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=child
|
||||||
|
)
|
||||||
|
result = assess(
|
||||||
|
worktree_path=repo,
|
||||||
|
head_sha=child,
|
||||||
|
remote_head_sha=parent,
|
||||||
|
pr_head_sha=parent,
|
||||||
|
head_ancestry=obs,
|
||||||
|
lock=make_lock(worktree_path=repo),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
|
||||||
|
|
||||||
|
install_deterministic_remote_urls()
|
||||||
|
"""#781: sanctioned issue title/body editing, and the documentation drift guard.
|
||||||
|
|
||||||
|
Two defects are covered here. The first is that no MCP path could edit an issue
|
||||||
|
title or body at all, so an authorized correction had to be recorded as a
|
||||||
|
comment. The second is why nobody noticed: documentation named a tool that was
|
||||||
|
never registered, and nothing compared the two lists.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import anti_stomp_preflight # noqa: E402
|
||||||
|
import edit_issue # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import mcp_tool_inventory # noqa: E402
|
||||||
|
import task_capability_map # noqa: E402
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"ctx": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"edit-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "author",
|
||||||
|
"username": "author-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": [
|
||||||
|
"Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"Example-Org/Example-Repo",
|
||||||
|
"913443/eAgenda",
|
||||||
|
],
|
||||||
|
"execution_profile": "edit-author",
|
||||||
|
},
|
||||||
|
"read-only-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "author",
|
||||||
|
"username": "reader-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
|
"allowed_operations": ["gitea.read"],
|
||||||
|
"forbidden_operations": ["gitea.issue.comment"],
|
||||||
|
"allowed_repositories": [
|
||||||
|
"Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"Example-Org/Example-Repo",
|
||||||
|
"913443/eAgenda",
|
||||||
|
],
|
||||||
|
"execution_profile": "read-only-author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
ISSUE_NUMBER = 9
|
||||||
|
ORIGINAL_TITLE = "fix(mcp): original title"
|
||||||
|
ORIGINAL_BODY = "Original body.\n"
|
||||||
|
NEW_TITLE = "fix(mcp): corrected title"
|
||||||
|
NEW_BODY = "Corrected body.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _registered_tool_names() -> set[str]:
|
||||||
|
manager = mcp_server.mcp._tool_manager
|
||||||
|
return set((getattr(manager, "_tools", None) or {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule: request validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestValidateEditRequest(unittest.TestCase):
|
||||||
|
def test_no_field_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
edit_issue.validate_edit_request()
|
||||||
|
self.assertIn("At least one field", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_blank_title_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
edit_issue.validate_edit_request(title=" ")
|
||||||
|
self.assertIn("cannot be blank", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_non_string_title_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
edit_issue.validate_edit_request(title=42)
|
||||||
|
|
||||||
|
def test_non_string_body_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
edit_issue.validate_edit_request(body=["not", "a", "string"])
|
||||||
|
|
||||||
|
def test_empty_body_is_a_legitimate_edit(self):
|
||||||
|
self.assertEqual(edit_issue.validate_edit_request(body=""), {"body": ""})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule: planning against the pre-image
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestPlanIssueEdit(unittest.TestCase):
|
||||||
|
def _current(self, **overrides):
|
||||||
|
issue = {
|
||||||
|
"number": ISSUE_NUMBER,
|
||||||
|
"title": ORIGINAL_TITLE,
|
||||||
|
"body": ORIGINAL_BODY,
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
|
||||||
|
"assignees": [{"login": "author-user"}],
|
||||||
|
"milestone": {"title": "v1.2.0"},
|
||||||
|
}
|
||||||
|
issue.update(overrides)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
def test_title_only_sends_only_the_title(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
|
||||||
|
self.assertEqual(plan["payload"], {"title": NEW_TITLE})
|
||||||
|
self.assertEqual(plan["requested_fields"], ["title"])
|
||||||
|
self.assertFalse(plan["no_op"])
|
||||||
|
|
||||||
|
def test_body_only_sends_only_the_body(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(self._current(), body=NEW_BODY)
|
||||||
|
self.assertEqual(plan["payload"], {"body": NEW_BODY})
|
||||||
|
|
||||||
|
def test_combined_edit_sends_both(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(
|
||||||
|
self._current(), title=NEW_TITLE, body=NEW_BODY
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["payload"], {"title": NEW_TITLE, "body": NEW_BODY})
|
||||||
|
self.assertEqual(plan["requested_fields"], ["body", "title"])
|
||||||
|
|
||||||
|
def test_identical_content_is_an_explicit_no_op(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(
|
||||||
|
self._current(), title=ORIGINAL_TITLE, body=ORIGINAL_BODY
|
||||||
|
)
|
||||||
|
self.assertTrue(plan["no_op"])
|
||||||
|
self.assertEqual(plan["payload"], {})
|
||||||
|
self.assertTrue(plan["reasons"])
|
||||||
|
self.assertTrue(plan["safe_next_action"])
|
||||||
|
|
||||||
|
def test_partially_unchanged_request_sends_only_the_difference(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(
|
||||||
|
self._current(), title=ORIGINAL_TITLE, body=NEW_BODY
|
||||||
|
)
|
||||||
|
self.assertFalse(plan["no_op"])
|
||||||
|
self.assertEqual(plan["payload"], {"body": NEW_BODY})
|
||||||
|
self.assertEqual(plan["unchanged_fields"], ["title"])
|
||||||
|
|
||||||
|
def test_missing_body_is_compared_as_empty(self):
|
||||||
|
current = self._current()
|
||||||
|
current.pop("body")
|
||||||
|
plan = edit_issue.plan_issue_edit(current, body="")
|
||||||
|
self.assertTrue(plan["no_op"])
|
||||||
|
|
||||||
|
def test_preserved_snapshot_captures_untouched_fields(self):
|
||||||
|
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
|
||||||
|
before = plan["preserved_before"]
|
||||||
|
self.assertEqual(before["state"], "open")
|
||||||
|
self.assertEqual(before["labels"], ["type:bug", "mcp"])
|
||||||
|
self.assertEqual(before["assignees"], ["author-user"])
|
||||||
|
self.assertEqual(before["milestone"], "v1.2.0")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule: pull requests are refused
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestAssessIssueTarget(unittest.TestCase):
|
||||||
|
def test_issue_is_accepted(self):
|
||||||
|
target = edit_issue.assess_issue_target(
|
||||||
|
{"number": 9, "title": "t"}, issue_number=9
|
||||||
|
)
|
||||||
|
self.assertTrue(target["is_issue"])
|
||||||
|
self.assertEqual(target["reasons"], [])
|
||||||
|
|
||||||
|
def test_pull_request_is_refused_with_a_next_action(self):
|
||||||
|
target = edit_issue.assess_issue_target(
|
||||||
|
{"number": 9, "pull_request": {"merged": False}}, issue_number=9
|
||||||
|
)
|
||||||
|
self.assertFalse(target["is_issue"])
|
||||||
|
self.assertTrue(target["is_pull_request"])
|
||||||
|
self.assertIn("gitea_edit_pr", target["safe_next_action"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule: read-after-write verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestVerifyIssueEdit(unittest.TestCase):
|
||||||
|
def _plan(self, **kwargs):
|
||||||
|
current = {
|
||||||
|
"number": ISSUE_NUMBER,
|
||||||
|
"title": ORIGINAL_TITLE,
|
||||||
|
"body": ORIGINAL_BODY,
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{"name": "type:bug"}],
|
||||||
|
"assignees": [],
|
||||||
|
"milestone": None,
|
||||||
|
}
|
||||||
|
return edit_issue.plan_issue_edit(current, **kwargs)
|
||||||
|
|
||||||
|
def test_applied_content_verifies(self):
|
||||||
|
plan = self._plan(title=NEW_TITLE)
|
||||||
|
observed = {
|
||||||
|
"title": NEW_TITLE,
|
||||||
|
"body": ORIGINAL_BODY,
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{"name": "type:bug"}],
|
||||||
|
"assignees": [],
|
||||||
|
"milestone": None,
|
||||||
|
}
|
||||||
|
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||||
|
self.assertTrue(result["verified"])
|
||||||
|
self.assertTrue(result["preserved_intact"])
|
||||||
|
self.assertEqual(result["applied"], {"title": NEW_TITLE})
|
||||||
|
|
||||||
|
def test_unapplied_content_fails_closed(self):
|
||||||
|
plan = self._plan(title=NEW_TITLE)
|
||||||
|
observed = {
|
||||||
|
"title": ORIGINAL_TITLE,
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{"name": "type:bug"}],
|
||||||
|
}
|
||||||
|
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertEqual(result["mismatches"][0]["field"], "title")
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_dropped_label_fails_closed(self):
|
||||||
|
plan = self._plan(title=NEW_TITLE)
|
||||||
|
observed = {"title": NEW_TITLE, "state": "open", "labels": []}
|
||||||
|
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertFalse(result["preserved_intact"])
|
||||||
|
self.assertEqual(result["preserved_changed"][0]["field"], "labels")
|
||||||
|
|
||||||
|
def test_changed_state_fails_closed(self):
|
||||||
|
plan = self._plan(body=NEW_BODY)
|
||||||
|
observed = {
|
||||||
|
"body": NEW_BODY,
|
||||||
|
"state": "closed",
|
||||||
|
"labels": [{"name": "type:bug"}],
|
||||||
|
}
|
||||||
|
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertEqual(result["preserved_changed"][0]["field"], "state")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: gitea_edit_issue against a fake Gitea
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class _EditIssueToolHarness(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(CONFIG))
|
||||||
|
|
||||||
|
self.issue = {
|
||||||
|
"number": ISSUE_NUMBER,
|
||||||
|
"title": ORIGINAL_TITLE,
|
||||||
|
"body": ORIGINAL_BODY,
|
||||||
|
"state": "open",
|
||||||
|
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
|
||||||
|
"assignees": [{"login": "author-user"}],
|
||||||
|
"milestone": {"title": "v1.2.0"},
|
||||||
|
"html_url": "https://gitea.example.com/Example-Org/Example-Repo/issues/9",
|
||||||
|
}
|
||||||
|
self.calls: list[tuple[str, str]] = []
|
||||||
|
self.patched_payloads: list[dict] = []
|
||||||
|
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
patch("mcp_server.get_auth_header", return_value="token author-pass").start()
|
||||||
|
patch("mcp_server.api_request", side_effect=self._api).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._remotes.stop()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _api(self, method, url, auth, payload=None):
|
||||||
|
self.calls.append((method, url))
|
||||||
|
if url.endswith("/user"):
|
||||||
|
return {"login": "author-user"}
|
||||||
|
if "/issues/" in url:
|
||||||
|
if method == "GET":
|
||||||
|
return dict(self.issue)
|
||||||
|
if method == "PATCH":
|
||||||
|
self.patched_payloads.append(dict(payload or {}))
|
||||||
|
self.issue.update(payload or {})
|
||||||
|
return dict(self.issue)
|
||||||
|
raise AssertionError(f"unexpected API call: {method} {url}")
|
||||||
|
|
||||||
|
def _env(self, profile: str = "edit-author") -> dict:
|
||||||
|
return {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile,
|
||||||
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "issue_781_edit_issue"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _edit(self, profile: str = "edit-author", **kwargs):
|
||||||
|
with patch.dict(os.environ, self._env(profile), clear=True):
|
||||||
|
return mcp_server.gitea_edit_issue(
|
||||||
|
issue_number=ISSUE_NUMBER, remote="prgs", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patch_methods(self) -> list[str]:
|
||||||
|
return [method for method, _url in self.calls if method == "PATCH"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditIssueSucceeds(_EditIssueToolHarness):
|
||||||
|
def test_title_only_edit(self):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["verified"])
|
||||||
|
self.assertEqual(result["changed_fields"], ["title"])
|
||||||
|
self.assertEqual(self.patched_payloads, [{"title": NEW_TITLE}])
|
||||||
|
self.assertEqual(self.issue["title"], NEW_TITLE)
|
||||||
|
self.assertEqual(self.issue["body"], ORIGINAL_BODY)
|
||||||
|
|
||||||
|
def test_body_only_edit(self):
|
||||||
|
result = self._edit(body=NEW_BODY)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(self.patched_payloads, [{"body": NEW_BODY}])
|
||||||
|
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
|
||||||
|
self.assertEqual(self.issue["body"], NEW_BODY)
|
||||||
|
|
||||||
|
def test_combined_edit(self):
|
||||||
|
result = self._edit(title=NEW_TITLE, body=NEW_BODY)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.patched_payloads, [{"title": NEW_TITLE, "body": NEW_BODY}]
|
||||||
|
)
|
||||||
|
self.assertEqual(result["applied"], {"title": NEW_TITLE, "body": NEW_BODY})
|
||||||
|
|
||||||
|
def test_body_can_be_cleared(self):
|
||||||
|
result = self._edit(body="")
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(self.issue["body"], "")
|
||||||
|
|
||||||
|
def test_targets_the_issue_endpoint_never_the_pull_endpoint(self):
|
||||||
|
self._edit(title=NEW_TITLE)
|
||||||
|
patched = [url for method, url in self.calls if method == "PATCH"]
|
||||||
|
self.assertTrue(patched)
|
||||||
|
for url in patched:
|
||||||
|
self.assertIn("/issues/", url)
|
||||||
|
self.assertNotIn("/pulls/", url)
|
||||||
|
|
||||||
|
def test_labels_state_assignee_and_milestone_are_provably_unchanged(self):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
proof = result["read_after_write"]
|
||||||
|
self.assertTrue(proof["preserved_intact"])
|
||||||
|
self.assertEqual(proof["preserved_before"], proof["preserved_after"])
|
||||||
|
self.assertEqual(proof["preserved_after"]["labels"], ["type:bug", "mcp"])
|
||||||
|
self.assertEqual(proof["preserved_after"]["state"], "open")
|
||||||
|
self.assertEqual(proof["preserved_after"]["assignees"], ["author-user"])
|
||||||
|
self.assertEqual(proof["preserved_after"]["milestone"], "v1.2.0")
|
||||||
|
|
||||||
|
def test_read_after_write_re_reads_the_issue(self):
|
||||||
|
self._edit(title=NEW_TITLE)
|
||||||
|
issue_calls = [method for method, url in self.calls if "/issues/" in url]
|
||||||
|
self.assertEqual(issue_calls, ["GET", "PATCH", "GET"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditIssueFailsClosed(_EditIssueToolHarness):
|
||||||
|
def test_no_op_request_is_rejected_without_a_patch(self):
|
||||||
|
result = self._edit(title=ORIGINAL_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertTrue(result["no_op"])
|
||||||
|
self.assertEqual(self._patch_methods(), [])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_invalid_request_raises_before_any_api_call(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self._edit()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_blank_title_raises_before_any_api_call(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self._edit(title=" ")
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_authorization_failure_blocks_before_any_api_call(self):
|
||||||
|
result = self._edit(profile="read-only-author", title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("permission_report", result)
|
||||||
|
self.assertEqual(
|
||||||
|
result["permission_report"]["missing_permission"],
|
||||||
|
task_capability_map.required_permission("edit_issue"),
|
||||||
|
)
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_pull_request_target_is_refused_without_a_patch(self):
|
||||||
|
self.issue["pull_request"] = {"merged": False}
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertEqual(self._patch_methods(), [])
|
||||||
|
self.assertIn("gitea_edit_pr", result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_pre_read_transport_error_is_reported(self):
|
||||||
|
def boom(method, url, auth, payload=None):
|
||||||
|
raise RuntimeError("connection reset by peer")
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=boom):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_patch_transport_error_is_reported_not_swallowed(self):
|
||||||
|
def flaky(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
raise RuntimeError("gitea exploded")
|
||||||
|
return self._api(method, url, auth, payload)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=flaky):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("issue edit failed", result["reasons"][0])
|
||||||
|
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
|
||||||
|
|
||||||
|
def test_read_back_transport_error_reports_an_unverified_edit(self):
|
||||||
|
state = {"gets": 0}
|
||||||
|
|
||||||
|
def flaky(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/issues/" in url:
|
||||||
|
state["gets"] += 1
|
||||||
|
if state["gets"] > 1:
|
||||||
|
raise RuntimeError("read timed out")
|
||||||
|
return self._api(method, url, auth, payload)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=flaky):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(result["performed"])
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_unapplied_edit_fails_verification(self):
|
||||||
|
def sticky(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
self.calls.append((method, url))
|
||||||
|
# Report success but store nothing.
|
||||||
|
return dict(self.issue)
|
||||||
|
return self._api(method, url, auth, payload)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=sticky):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(result["performed"])
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["read_after_write"]["mismatches"][0]["field"], "title"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_edit_that_drops_a_label_fails_verification(self):
|
||||||
|
def label_eating(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
self.calls.append((method, url))
|
||||||
|
self.issue.update(payload or {})
|
||||||
|
self.issue["labels"] = []
|
||||||
|
return dict(self.issue)
|
||||||
|
return self._api(method, url, auth, payload)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=label_eating):
|
||||||
|
result = self._edit(title=NEW_TITLE)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["read_after_write"]["preserved_intact"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registration and gate wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestEditIssueRegistrationAndGates(unittest.TestCase):
|
||||||
|
def test_tool_is_registered(self):
|
||||||
|
self.assertIn("gitea_edit_issue", _registered_tool_names())
|
||||||
|
|
||||||
|
def test_resolver_task_exists_with_author_role(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("edit_issue"),
|
||||||
|
"gitea.issue.comment",
|
||||||
|
)
|
||||||
|
self.assertEqual(task_capability_map.required_role("edit_issue"), "author")
|
||||||
|
|
||||||
|
def test_tool_gate_matches_the_resolver_task(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.ISSUE_MUTATION_TOOL_TASKS["gitea_edit_issue"],
|
||||||
|
"edit_issue",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.tool_required_permission("gitea_edit_issue"),
|
||||||
|
task_capability_map.required_permission("edit_issue"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_declared_as_an_anti_stomp_mutation_task(self):
|
||||||
|
self.assertIn("edit_issue", anti_stomp_preflight.MUTATION_TASKS)
|
||||||
|
|
||||||
|
def test_edit_pr_remains_pull_request_only(self):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(mcp_server.gitea_edit_pr).parameters
|
||||||
|
self.assertIn("pr_number", params)
|
||||||
|
self.assertNotIn("issue_number", params)
|
||||||
|
|
||||||
|
def test_edit_issue_cannot_change_state_or_labels(self):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(mcp_server.gitea_edit_issue).parameters
|
||||||
|
self.assertEqual(
|
||||||
|
[name for name in params if name in ("title", "body")],
|
||||||
|
["title", "body"],
|
||||||
|
)
|
||||||
|
for forbidden in ("state", "labels", "assignee", "assignees", "milestone"):
|
||||||
|
self.assertNotIn(forbidden, params)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The drift guard itself
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestInventoryDriftRule(unittest.TestCase):
|
||||||
|
def test_missing_markers_fail_closed(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mcp_tool_inventory.parse_documented_inventory("no markers here")
|
||||||
|
|
||||||
|
def test_documented_but_unregistered_is_drift(self):
|
||||||
|
result = mcp_tool_inventory.assess_inventory_drift(
|
||||||
|
["gitea_edit_issue", "gitea_view_issue"], ["gitea_view_issue"]
|
||||||
|
)
|
||||||
|
self.assertFalse(result["in_sync"])
|
||||||
|
self.assertEqual(result["documented_not_registered"], ["gitea_edit_issue"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_registered_but_undocumented_is_drift(self):
|
||||||
|
result = mcp_tool_inventory.assess_inventory_drift(
|
||||||
|
["gitea_view_issue"], ["gitea_view_issue", "gitea_edit_issue"]
|
||||||
|
)
|
||||||
|
self.assertFalse(result["in_sync"])
|
||||||
|
self.assertEqual(result["registered_not_documented"], ["gitea_edit_issue"])
|
||||||
|
|
||||||
|
def test_unsorted_inventory_is_drift(self):
|
||||||
|
result = mcp_tool_inventory.assess_inventory_drift(
|
||||||
|
["gitea_view_issue", "gitea_edit_issue"],
|
||||||
|
["gitea_view_issue", "gitea_edit_issue"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["in_sync"])
|
||||||
|
self.assertFalse(result["sorted"])
|
||||||
|
|
||||||
|
def test_module_names_are_not_treated_as_tools(self):
|
||||||
|
self.assertFalse(mcp_tool_inventory.looks_like_tool_name("gitea_auth"))
|
||||||
|
self.assertTrue(mcp_tool_inventory.looks_like_tool_name("gitea_view_issue"))
|
||||||
|
|
||||||
|
def test_unregistered_doc_reference_is_reported(self):
|
||||||
|
result = mcp_tool_inventory.assess_doc_references(
|
||||||
|
{"skills/example.md": {"gitea_edit_issue"}}, ["gitea_view_issue"]
|
||||||
|
)
|
||||||
|
self.assertFalse(result["clean"])
|
||||||
|
self.assertEqual(result["unregistered"][0]["tool"], "gitea_edit_issue")
|
||||||
|
|
||||||
|
def test_rendered_block_round_trips(self):
|
||||||
|
block = mcp_tool_inventory.render_inventory_block(
|
||||||
|
["gitea_view_issue", "gitea_edit_issue"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_tool_inventory.parse_documented_inventory(block),
|
||||||
|
["gitea_edit_issue", "gitea_view_issue"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentationMatchesRegistry(unittest.TestCase):
|
||||||
|
"""The live guard: docs and the registry must not drift apart."""
|
||||||
|
|
||||||
|
def test_documented_inventory_equals_registered_tools(self):
|
||||||
|
doc = REPO_ROOT / mcp_tool_inventory.INVENTORY_DOC_PATH
|
||||||
|
self.assertTrue(doc.exists(), f"{doc} is missing")
|
||||||
|
documented = mcp_tool_inventory.parse_documented_inventory(
|
||||||
|
doc.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
result = mcp_tool_inventory.assess_inventory_drift(
|
||||||
|
documented, _registered_tool_names()
|
||||||
|
)
|
||||||
|
self.assertTrue(result["in_sync"], "; ".join(result["reasons"]))
|
||||||
|
|
||||||
|
def test_every_tool_named_in_the_skills_is_registered(self):
|
||||||
|
references: dict[str, set[str]] = {}
|
||||||
|
pattern = str(REPO_ROOT / "skills" / "**" / "*.md")
|
||||||
|
paths = glob.glob(pattern, recursive=True)
|
||||||
|
self.assertTrue(paths, "no skill documents found to check")
|
||||||
|
for path in paths:
|
||||||
|
text = Path(path).read_text(encoding="utf-8")
|
||||||
|
names = mcp_tool_inventory.extract_tool_references(text)
|
||||||
|
if names:
|
||||||
|
references[str(Path(path).relative_to(REPO_ROOT))] = names
|
||||||
|
result = mcp_tool_inventory.assess_doc_references(
|
||||||
|
references, _registered_tool_names()
|
||||||
|
)
|
||||||
|
self.assertTrue(result["clean"], "; ".join(result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
"""Tests for durable dependency edges (#784, umbrella #628 scope item 6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import allocator_dependencies
|
||||||
|
import dependency_graph
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
import mcp_tool_inventory
|
||||||
|
from control_plane_db import SCHEMA_VERSION, ControlPlaneDB, ControlPlaneError
|
||||||
|
|
||||||
|
ISSUE = dependency_graph.WORK_KIND_ISSUE
|
||||||
|
PR = dependency_graph.WORK_KIND_PR
|
||||||
|
EDGE_BLOCKED = dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
|
||||||
|
|
||||||
|
# Schema as it stood before this change, used to prove a real v3 → v4 migration
|
||||||
|
# rather than a fresh-database creation dressed up as one.
|
||||||
|
_V3_SCHEMA = """
|
||||||
|
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
profile TEXT,
|
||||||
|
namespace TEXT,
|
||||||
|
pid INTEGER,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
last_heartbeat_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
);
|
||||||
|
CREATE TABLE work_items (
|
||||||
|
work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
remote TEXT NOT NULL,
|
||||||
|
org TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('issue', 'pr')),
|
||||||
|
number INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'open',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_head_sha TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (remote, org, repo, kind, number)
|
||||||
|
);
|
||||||
|
CREATE TABLE leases (
|
||||||
|
lease_id TEXT PRIMARY KEY,
|
||||||
|
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL DEFAULT 'claimed',
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
heartbeat_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
);
|
||||||
|
CREATE TABLE assignments (
|
||||||
|
assignment_id TEXT PRIMARY KEY,
|
||||||
|
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||||
|
lease_id TEXT NOT NULL REFERENCES leases(lease_id),
|
||||||
|
allowed_actions TEXT NOT NULL,
|
||||||
|
forbidden_actions TEXT NOT NULL,
|
||||||
|
expected_head_sha TEXT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE terminal_locks (
|
||||||
|
terminal_lock_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
remote TEXT NOT NULL,
|
||||||
|
org TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
terminal_pr INTEGER NOT NULL,
|
||||||
|
review_id TEXT,
|
||||||
|
decision TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
cleanup_state TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (remote, org, repo, terminal_pr)
|
||||||
|
);
|
||||||
|
CREATE TABLE events (
|
||||||
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
work_item_id INTEGER REFERENCES work_items(work_item_id),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE incident_links (
|
||||||
|
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
provider_org TEXT NOT NULL DEFAULT '',
|
||||||
|
provider_project TEXT NOT NULL DEFAULT '',
|
||||||
|
provider_issue_id TEXT NOT NULL,
|
||||||
|
provider_short_id TEXT,
|
||||||
|
provider_permalink TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
|
gitea_org TEXT NOT NULL,
|
||||||
|
gitea_repo TEXT NOT NULL,
|
||||||
|
gitea_issue_number INTEGER NOT NULL,
|
||||||
|
linked_pr_numbers TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
last_seen TEXT,
|
||||||
|
event_count INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
release_resolved_at TEXT,
|
||||||
|
last_sync_at TEXT,
|
||||||
|
UNIQUE (provider, provider_base_url, provider_org, provider_project,
|
||||||
|
provider_issue_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_kwargs(**overrides):
|
||||||
|
base = {
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"source_kind": ISSUE,
|
||||||
|
"source_number": 784,
|
||||||
|
"target_kind": ISSUE,
|
||||||
|
"target_number": 628,
|
||||||
|
"edge_type": EDGE_BLOCKED,
|
||||||
|
"state": dependency_graph.STATE_UNMET,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class VocabularyTest(unittest.TestCase):
|
||||||
|
"""AC4, AC5: the edge vocabulary is complete and fails closed."""
|
||||||
|
|
||||||
|
def test_all_seven_umbrella_relationship_types_exist(self) -> None:
|
||||||
|
self.assertEqual(len(dependency_graph.EDGE_TYPES), 7)
|
||||||
|
for edge_type in (
|
||||||
|
dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||||
|
dependency_graph.EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
|
||||||
|
dependency_graph.EDGE_MERGE_WAITING_FOR_APPROVAL,
|
||||||
|
dependency_graph.EDGE_RECONCILIATION_WAITING_FOR_MERGE,
|
||||||
|
dependency_graph.EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
|
||||||
|
dependency_graph.EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
|
||||||
|
dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
|
||||||
|
):
|
||||||
|
self.assertIn(edge_type, dependency_graph.EDGE_TYPES)
|
||||||
|
blocking, completion = dependency_graph.default_conditions(edge_type)
|
||||||
|
self.assertTrue(blocking and completion)
|
||||||
|
|
||||||
|
def test_states_match_the_resolver_partitions(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
dependency_graph.EDGE_STATES,
|
||||||
|
frozenset({"unmet", "met", "unavailable"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_edge_type_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
|
||||||
|
dependency_graph.normalize_edge_type("waits_for_vibes")
|
||||||
|
|
||||||
|
def test_unknown_state_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
|
||||||
|
dependency_graph.normalize_edge_state("probably_fine")
|
||||||
|
|
||||||
|
def test_non_work_endpoint_kind_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
|
||||||
|
dependency_graph.normalize_work_kind("incident")
|
||||||
|
|
||||||
|
def test_evidence_sanitization_strips_credentials_and_urls(self) -> None:
|
||||||
|
clean = dependency_graph.sanitize_evidence(
|
||||||
|
{
|
||||||
|
"token": "abc123",
|
||||||
|
"authorization": "Bearer xyz",
|
||||||
|
"note": "fetched from https://gitea.example.invalid/api/v1/x",
|
||||||
|
"nested": [{"api_key": "k"}, "plain"],
|
||||||
|
"observed_state": "closed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(clean["token"], dependency_graph.REDACTED)
|
||||||
|
self.assertEqual(clean["authorization"], dependency_graph.REDACTED)
|
||||||
|
self.assertNotIn("https://", clean["note"])
|
||||||
|
self.assertEqual(clean["nested"][0]["api_key"], dependency_graph.REDACTED)
|
||||||
|
self.assertEqual(clean["observed_state"], "closed")
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaTest(unittest.TestCase):
|
||||||
|
"""AC1-AC3: schema creation, migration, and idempotence."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _tables(self) -> set[str]:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _schema_version(self) -> str:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return str(row[0]) if row else ""
|
||||||
|
|
||||||
|
def test_fresh_database_is_v4_with_the_edge_table(self) -> None:
|
||||||
|
ControlPlaneDB(self.db_path)
|
||||||
|
self.assertEqual(SCHEMA_VERSION, 4)
|
||||||
|
self.assertEqual(self._schema_version(), "4")
|
||||||
|
self.assertIn("dependency_edges", self._tables())
|
||||||
|
|
||||||
|
def _seed_v3(self) -> None:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.executescript(_V3_SCHEMA)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', '3')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO work_items(
|
||||||
|
remote, org, repo, kind, number, state, priority, updated_at
|
||||||
|
) VALUES ('prgs', 'O', 'R', 'issue', 601, 'open', 20,
|
||||||
|
'2026-07-01T00:00:00Z')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at)
|
||||||
|
VALUES ('legacy-session', 'author', '2026-07-01T00:00:00Z',
|
||||||
|
'2026-07-01T00:00:00Z')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||||
|
VALUES (1, 'legacy', 'kept', '2026-07-01T00:00:00Z')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_v3_database_migrates_in_place_without_losing_rows(self) -> None:
|
||||||
|
self._seed_v3()
|
||||||
|
self.assertNotIn("dependency_edges", self._tables())
|
||||||
|
|
||||||
|
ControlPlaneDB(self.db_path)
|
||||||
|
|
||||||
|
self.assertEqual(self._schema_version(), "4")
|
||||||
|
self.assertIn("dependency_edges", self._tables())
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0], 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT message FROM events WHERE event_type = 'legacy'"
|
||||||
|
).fetchone()[0],
|
||||||
|
"kept",
|
||||||
|
)
|
||||||
|
for table in ("leases", "assignments", "terminal_locks", "incident_links"):
|
||||||
|
conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(self) -> None:
|
||||||
|
self._seed_v3()
|
||||||
|
ControlPlaneDB(self.db_path)
|
||||||
|
db = ControlPlaneDB(self.db_path) # second open re-runs the migration
|
||||||
|
ControlPlaneDB(self.db_path)
|
||||||
|
|
||||||
|
self.assertEqual(self._schema_version(), "4")
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
tables = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||||
|
"AND name = 'dependency_edges'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(len(tables), 1)
|
||||||
|
self.assertEqual(db.list_dependency_edges(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class EdgePersistenceTest(unittest.TestCase):
|
||||||
|
"""AC5-AC10: storage, uniqueness, lookup, scope, audit, redaction."""
|
||||||
|
|
||||||
|
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 _events(self) -> list[tuple[str, str]]:
|
||||||
|
conn = sqlite3.connect(self.db.db_path)
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
(str(row[0]), str(row[1]))
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT event_type, message FROM events"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_invalid_values_write_nothing(self) -> None:
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(edge_type="nonsense"))
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(state="maybe"))
|
||||||
|
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(target_kind="incident"))
|
||||||
|
self.assertEqual(self.db.list_dependency_edges(), [])
|
||||||
|
|
||||||
|
def test_stored_edge_carries_the_full_contract(self) -> None:
|
||||||
|
edge = self.db.upsert_dependency_edge(
|
||||||
|
**_edge_kwargs(evidence={"observed_state": "not_closed"})
|
||||||
|
)
|
||||||
|
self.assertEqual(edge["source_number"], 784)
|
||||||
|
self.assertEqual(edge["target_number"], 628)
|
||||||
|
self.assertEqual(edge["edge_type"], EDGE_BLOCKED)
|
||||||
|
self.assertEqual(edge["state"], "unmet")
|
||||||
|
self.assertEqual(edge["blocking_condition"], "target issue is not closed")
|
||||||
|
self.assertEqual(edge["completion_condition"], "target issue is closed")
|
||||||
|
self.assertEqual(edge["evidence"], {"observed_state": "not_closed"})
|
||||||
|
self.assertTrue(edge["created_at"])
|
||||||
|
self.assertTrue(edge["last_observed_at"])
|
||||||
|
|
||||||
|
def test_repeated_upsert_updates_one_row(self) -> None:
|
||||||
|
first = self.db.upsert_dependency_edge(**_edge_kwargs())
|
||||||
|
second = self.db.upsert_dependency_edge(
|
||||||
|
**_edge_kwargs(state="met", evidence={"observed_state": "closed"})
|
||||||
|
)
|
||||||
|
self.assertEqual(first["edge_id"], second["edge_id"])
|
||||||
|
edges = self.db.list_dependency_edges()
|
||||||
|
self.assertEqual(len(edges), 1)
|
||||||
|
self.assertEqual(edges[0]["state"], "met")
|
||||||
|
self.assertEqual(edges[0]["evidence"], {"observed_state": "closed"})
|
||||||
|
|
||||||
|
def test_upsert_state_change_is_audited(self) -> None:
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs())
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs()) # unchanged: no event
|
||||||
|
self.assertEqual(self._events(), [])
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(state="met"))
|
||||||
|
events = self._events()
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0][0], "dependency_edge_state_change")
|
||||||
|
self.assertIn("unmet -> met", events[0][1])
|
||||||
|
|
||||||
|
def test_reverse_lookup_finds_every_waiter(self) -> None:
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=784))
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=790))
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
**_edge_kwargs(
|
||||||
|
source_kind=PR,
|
||||||
|
source_number=791,
|
||||||
|
edge_type=dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
**_edge_kwargs(source_number=792, target_number=999)
|
||||||
|
)
|
||||||
|
|
||||||
|
waiters = self.db.list_dependency_edges(target_kind=ISSUE, target_number=628)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(edge["source_number"] for edge in waiters), [784, 790, 791]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forward_lookup_and_state_filter(self) -> None:
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=628))
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=603, state="met"))
|
||||||
|
blockers = self.db.list_dependency_edges(source_number=784, state="unmet")
|
||||||
|
self.assertEqual([edge["target_number"] for edge in blockers], [628])
|
||||||
|
|
||||||
|
def test_scope_isolation(self) -> None:
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs())
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(repo="Other-Repo"))
|
||||||
|
self.assertEqual(
|
||||||
|
len(self.db.list_dependency_edges(remote="prgs", repo="Gitea-Tools")), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
len(self.db.list_dependency_edges(remote="prgs", repo="Other-Repo")), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.db.list_dependency_edges(remote="dadeschools")), 0)
|
||||||
|
|
||||||
|
def test_observation_records_transition_with_prior_state(self) -> None:
|
||||||
|
edge = self.db.upsert_dependency_edge(**_edge_kwargs())
|
||||||
|
updated = self.db.record_dependency_edge_observation(
|
||||||
|
edge["edge_id"],
|
||||||
|
state="met",
|
||||||
|
evidence={"observed_state": "closed"},
|
||||||
|
detail="target closed by merge",
|
||||||
|
)
|
||||||
|
self.assertEqual(updated["prior_state"], "unmet")
|
||||||
|
self.assertEqual(updated["state"], "met")
|
||||||
|
self.assertTrue(updated["state_changed"])
|
||||||
|
events = self._events()
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertIn("unmet -> met", events[0][1])
|
||||||
|
self.assertIn("target closed by merge", events[0][1])
|
||||||
|
|
||||||
|
def test_observation_on_unknown_edge_fails_closed(self) -> None:
|
||||||
|
with self.assertRaises(ControlPlaneError):
|
||||||
|
self.db.record_dependency_edge_observation("no-such-edge", state="met")
|
||||||
|
|
||||||
|
def test_evidence_never_persists_a_credential_or_endpoint(self) -> None:
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
**_edge_kwargs(
|
||||||
|
evidence={
|
||||||
|
"token": "super-secret",
|
||||||
|
"source": "GET https://gitea.example.invalid/api/v1/issues/628",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn = sqlite3.connect(self.db.db_path)
|
||||||
|
try:
|
||||||
|
raw = conn.execute("SELECT evidence FROM dependency_edges").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertNotIn("super-secret", raw)
|
||||||
|
self.assertNotIn("https://", raw)
|
||||||
|
stored = json.loads(raw)
|
||||||
|
self.assertEqual(stored["token"], dependency_graph.REDACTED)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.list_dependency_edges()[0]["evidence"]["token"],
|
||||||
|
dependency_graph.REDACTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionIngestionTest(unittest.TestCase):
|
||||||
|
"""AC11, AC12: allocation-run ingestion and write-failure tolerance."""
|
||||||
|
|
||||||
|
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 _resolution(self):
|
||||||
|
# Same call the allocator makes: parse the body, resolve live state.
|
||||||
|
body = "* Parent: #628 · Depends: #601, #603, #999 · Related: #613"
|
||||||
|
refs = allocator_dependencies.parse_dependency_refs(body)
|
||||||
|
live = {601: "closed", 603: "open", 999: None}
|
||||||
|
return allocator_dependencies.resolve_dependency_state(
|
||||||
|
refs, lambda n: live[n], subject="issue#784"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_one_edge_per_reference_with_matching_state(self) -> None:
|
||||||
|
resolution = self._resolution()
|
||||||
|
reasons = dependency_graph.record_issue_dependency_edges(
|
||||||
|
self.db,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_number=784,
|
||||||
|
resolution=resolution,
|
||||||
|
observed_by="prgs-author-1234-abcd",
|
||||||
|
)
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
|
||||||
|
edges = {
|
||||||
|
edge["target_number"]: edge
|
||||||
|
for edge in self.db.list_dependency_edges(source_number=784)
|
||||||
|
}
|
||||||
|
self.assertEqual(sorted(edges), [601, 603, 999])
|
||||||
|
self.assertEqual(edges[601]["state"], "met")
|
||||||
|
self.assertEqual(edges[603]["state"], "unmet")
|
||||||
|
self.assertEqual(edges[999]["state"], "unavailable")
|
||||||
|
self.assertEqual(edges[999]["evidence"]["observed_state"], "unavailable")
|
||||||
|
self.assertEqual(
|
||||||
|
edges[603]["evidence"]["observed_by_session"], "prgs-author-1234-abcd"
|
||||||
|
)
|
||||||
|
self.assertEqual(edges[601]["edge_type"], EDGE_BLOCKED)
|
||||||
|
|
||||||
|
def test_unavailable_evidence_is_never_recorded_as_met(self) -> None:
|
||||||
|
resolution = self._resolution()
|
||||||
|
dependency_graph.record_issue_dependency_edges(
|
||||||
|
self.db,
|
||||||
|
remote="prgs",
|
||||||
|
org="O",
|
||||||
|
repo="R",
|
||||||
|
source_number=784,
|
||||||
|
resolution=resolution,
|
||||||
|
)
|
||||||
|
met = self.db.list_dependency_edges(state="met")
|
||||||
|
self.assertEqual([edge["target_number"] for edge in met], [601])
|
||||||
|
|
||||||
|
def test_store_write_failure_is_reported_not_raised(self) -> None:
|
||||||
|
class BrokenStore:
|
||||||
|
def upsert_dependency_edge(self, **_kwargs):
|
||||||
|
raise RuntimeError("disk is on fire")
|
||||||
|
|
||||||
|
reasons = dependency_graph.record_issue_dependency_edges(
|
||||||
|
BrokenStore(),
|
||||||
|
remote="prgs",
|
||||||
|
org="O",
|
||||||
|
repo="R",
|
||||||
|
source_number=784,
|
||||||
|
resolution=self._resolution(),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(reasons), 3)
|
||||||
|
self.assertTrue(all("disk is on fire" in reason for reason in reasons))
|
||||||
|
|
||||||
|
def test_no_declared_dependencies_writes_nothing(self) -> None:
|
||||||
|
resolution = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(), lambda n: "closed", subject="issue#784"
|
||||||
|
)
|
||||||
|
reasons = dependency_graph.record_issue_dependency_edges(
|
||||||
|
self.db,
|
||||||
|
remote="prgs",
|
||||||
|
org="O",
|
||||||
|
repo="R",
|
||||||
|
source_number=784,
|
||||||
|
resolution=resolution,
|
||||||
|
)
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
self.assertEqual(self.db.list_dependency_edges(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class AllocationRunIngestionTest(unittest.TestCase):
|
||||||
|
"""AC11, AC12, AC14: the live allocator path writes edges without changing
|
||||||
|
what it selects."""
|
||||||
|
|
||||||
|
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 _issue(number: int, *, body: str = "") -> dict:
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"issue {number}",
|
||||||
|
"body": body,
|
||||||
|
"labels": [{"name": "status:ready"}],
|
||||||
|
"state": "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_gitea(self, issues, *, closed=()):
|
||||||
|
closed_set = set(closed)
|
||||||
|
|
||||||
|
def api_get_all(url, _auth, **_kw):
|
||||||
|
if "/pulls" in url:
|
||||||
|
return []
|
||||||
|
return list(issues)
|
||||||
|
|
||||||
|
def api_request(_method, url, _auth, **_kw):
|
||||||
|
number = int(url.rsplit("/", 1)[-1])
|
||||||
|
state = "closed" if number in closed_set else "open"
|
||||||
|
return {"number": number, "state": state}
|
||||||
|
|
||||||
|
return api_get_all, api_request
|
||||||
|
|
||||||
|
def _allocate(self, issues, *, closed=(), db, **kwargs):
|
||||||
|
api_get_all, api_request = self._fake_gitea(issues, closed=closed)
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._profile_operation_gate", return_value=None
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._auth", return_value="token REDACTED"
|
||||||
|
), 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=(db, [])
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_get_all", side_effect=api_get_all
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_request", side_effect=api_request
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||||
|
):
|
||||||
|
return srv.gitea_allocate_next_work(
|
||||||
|
remote="prgs", org="O", repo="R", role="author", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_run_persists_one_edge_per_declared_reference(self) -> None:
|
||||||
|
issues = [
|
||||||
|
self._issue(600, body="* Parent: #900 · Depends: #601, #500"),
|
||||||
|
self._issue(601),
|
||||||
|
self._issue(602),
|
||||||
|
]
|
||||||
|
result = self._allocate(issues, closed={500}, db=self.db)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
edges = self.db.list_dependency_edges(remote="prgs", org="O", repo="R")
|
||||||
|
by_target = {edge["target_number"]: edge for edge in edges}
|
||||||
|
self.assertEqual(sorted(by_target), [500, 601])
|
||||||
|
self.assertEqual(by_target[601]["state"], "unmet")
|
||||||
|
self.assertEqual(by_target[500]["state"], "met")
|
||||||
|
self.assertEqual(by_target[601]["source_number"], 600)
|
||||||
|
self.assertEqual(
|
||||||
|
by_target[601]["edge_type"], dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
|
||||||
|
)
|
||||||
|
self.assertTrue(by_target[601]["evidence"]["observed_by_session"])
|
||||||
|
|
||||||
|
def test_selection_is_unchanged_by_the_store(self) -> None:
|
||||||
|
issues = [
|
||||||
|
self._issue(600, body="* Depends: #601"),
|
||||||
|
self._issue(601),
|
||||||
|
self._issue(602),
|
||||||
|
]
|
||||||
|
|
||||||
|
class DeadStore:
|
||||||
|
"""Stands in for a control-plane DB whose edge writes all fail."""
|
||||||
|
|
||||||
|
def __init__(self, real):
|
||||||
|
self._real = real
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._real, name)
|
||||||
|
|
||||||
|
def upsert_dependency_edge(self, **_kwargs):
|
||||||
|
raise RuntimeError("edge store unavailable")
|
||||||
|
|
||||||
|
healthy = self._allocate(issues, db=self.db)
|
||||||
|
broken = self._allocate(issues, db=DeadStore(self.db))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
healthy["selected"]["number"], broken["selected"]["number"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{s["number"] for s in healthy["skipped"]},
|
||||||
|
{s["number"] for s in broken["skipped"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(healthy["candidate_count"], broken["candidate_count"])
|
||||||
|
self.assertTrue(broken["success"])
|
||||||
|
warnings = broken.get("inventory_warnings") or []
|
||||||
|
self.assertTrue(
|
||||||
|
any("edge store unavailable" in str(w) for w in warnings),
|
||||||
|
f"write failure must surface in reasons, got {warnings}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repeated_runs_do_not_duplicate_edges(self) -> None:
|
||||||
|
issues = [self._issue(600, body="* Depends: #601"), self._issue(601)]
|
||||||
|
self._allocate(issues, db=self.db)
|
||||||
|
self._allocate(issues, db=self.db)
|
||||||
|
self.assertEqual(len(self.db.list_dependency_edges()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ListDependencyEdgesToolTest(unittest.TestCase):
|
||||||
|
"""AC13: the read-only tool is gated and never mutates."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
self.db.upsert_dependency_edge(**_edge_kwargs(org="O", repo="R"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _call(self, *, read_block=None, **kwargs):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._profile_operation_gate", return_value=read_block
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._permission_block_report", return_value={"blocked": True}
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||||
|
):
|
||||||
|
return srv.gitea_list_dependency_edges(remote="prgs", **kwargs)
|
||||||
|
|
||||||
|
def test_returns_stored_edges(self) -> None:
|
||||||
|
result = self._call()
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["read_only"])
|
||||||
|
self.assertEqual(result["count"], 1)
|
||||||
|
self.assertEqual(result["edges"][0]["target_number"], 628)
|
||||||
|
self.assertEqual(len(result["edge_types"]), 7)
|
||||||
|
|
||||||
|
def test_reverse_lookup_filter(self) -> None:
|
||||||
|
self.assertEqual(self._call(target_number=628)["count"], 1)
|
||||||
|
self.assertEqual(self._call(target_number=999)["count"], 0)
|
||||||
|
|
||||||
|
def test_without_read_permission_it_fails_closed(self) -> None:
|
||||||
|
result = self._call(read_block=["gitea.read not allowed"])
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["edges"], [])
|
||||||
|
self.assertIn("permission_report", result)
|
||||||
|
|
||||||
|
def test_invalid_filter_fails_closed(self) -> None:
|
||||||
|
result = self._call(edge_type="not_a_real_type")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["edges"], [])
|
||||||
|
self.assertTrue(any("fail closed" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_tool_is_documented_in_the_inventory(self) -> None:
|
||||||
|
"""The #781 drift guard requires a registered tool to be documented."""
|
||||||
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
doc = os.path.join(repo_root, mcp_tool_inventory.INVENTORY_DOC_PATH)
|
||||||
|
with open(doc, "r", encoding="utf-8") as handle:
|
||||||
|
documented = mcp_tool_inventory.parse_documented_inventory(handle.read())
|
||||||
|
self.assertIn("gitea_list_dependency_edges", documented)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
@@ -10,6 +10,7 @@ DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
|
|||||||
|
|
||||||
REQUIRED_MENU_LABELS = (
|
REQUIRED_MENU_LABELS = (
|
||||||
"Project status / root checkout health",
|
"Project status / root checkout health",
|
||||||
|
"Workflow dashboard (queue, leases, next safe action)",
|
||||||
"Author workflow prompts",
|
"Author workflow prompts",
|
||||||
"Reviewer workflow prompts",
|
"Reviewer workflow prompts",
|
||||||
"Merger workflow prompts",
|
"Merger workflow prompts",
|
||||||
@@ -105,6 +106,23 @@ class TestMcpMenuScript(unittest.TestCase):
|
|||||||
self.assertIn("./mcp-menu.sh", docs_text)
|
self.assertIn("./mcp-menu.sh", docs_text)
|
||||||
self.assertIn("placeholder", docs_text.lower())
|
self.assertIn("placeholder", docs_text.lower())
|
||||||
|
|
||||||
|
def test_workflow_dashboard_menu_entry_is_read_only(self):
|
||||||
|
# #605: dashboard entry documents gitea_workflow_dashboard and never
|
||||||
|
# mutates Gitea / assigns work from the shell menu.
|
||||||
|
label = "Workflow dashboard (queue, leases, next safe action)"
|
||||||
|
self.assertIn(label, self.content)
|
||||||
|
dash_fn = self._extract_function("show_workflow_dashboard_help")
|
||||||
|
self.assertIn("gitea_workflow_dashboard", dash_fn)
|
||||||
|
self.assertIn("gitea_allocate_next_work", dash_fn)
|
||||||
|
self.assertIn("Read-only", dash_fn)
|
||||||
|
self.assertIn("never presented as safe", dash_fn.lower())
|
||||||
|
for bad in ("gitea_merge_pr", "gitea_submit_pr_review", "git push"):
|
||||||
|
with self.subTest(bad=bad):
|
||||||
|
self.assertNotIn(bad, dash_fn)
|
||||||
|
docs_text = DOCS.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("gitea_workflow_dashboard", docs_text)
|
||||||
|
self.assertIn("Workflow dashboard", docs_text)
|
||||||
|
|
||||||
def test_reviewer_skip_stale_request_changes_prompt_discoverable(self):
|
def test_reviewer_skip_stale_request_changes_prompt_discoverable(self):
|
||||||
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
|
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
|
||||||
# must be reachable from the reviewer menu and documented.
|
# must be reachable from the reviewer menu and documented.
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Tests for the #617 mutation-budget classifier.
|
||||||
|
|
||||||
|
Covers every acceptance criterion on issue #617:
|
||||||
|
|
||||||
|
* AC1 — the classifier distinguishes local validator rejection, capability-gate
|
||||||
|
rejection, transport failure before API, and successful server-side mutation.
|
||||||
|
* AC2 — pre-API validator failures do not consume server-side mutation budget.
|
||||||
|
* AC3 — failed attempts are still logged in the local attempt ledger.
|
||||||
|
* AC4 — the final report separately shows local failed attempts, blocked API
|
||||||
|
attempts, and successful server-side mutations.
|
||||||
|
* AC5 — the six named scenarios, including the #615 reproduction where two
|
||||||
|
local validator rejections precede one successful comment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from mutation_budget_classifier import (
|
||||||
|
AMBIGUOUS_REQUIRES_READBACK,
|
||||||
|
CAPABILITY_GATE_REJECTION,
|
||||||
|
LOCAL_VALIDATOR_REJECTION,
|
||||||
|
SERVER_SIDE_MUTATION,
|
||||||
|
TRANSPORT_FAILURE_BEFORE_API,
|
||||||
|
assess_final_report_mutation_accounting,
|
||||||
|
classify_mutation_attempt,
|
||||||
|
record_attempt,
|
||||||
|
summarize_attempt_ledger,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The two pre-API rejections observed on the #615 comment flow.
|
||||||
|
MISSING_LEDGER_BLOCK = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"api_called": False,
|
||||||
|
"reasons": ["missing [THREAD STATE LEDGER] block"],
|
||||||
|
}
|
||||||
|
|
||||||
|
MISSING_CANONICAL_STATE = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"api_called": False,
|
||||||
|
"reasons": ["missing ## Canonical Issue State block"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# The corrected comment that actually landed as #615 comment 9137.
|
||||||
|
SUCCESSFUL_COMMENT = {
|
||||||
|
"success": True,
|
||||||
|
"performed": True,
|
||||||
|
"api_called": True,
|
||||||
|
"comment_id": 9137,
|
||||||
|
"issue_number": 615,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC1Classification(unittest.TestCase):
|
||||||
|
"""AC1: the four outcome classes are distinguished."""
|
||||||
|
|
||||||
|
def test_local_validator_rejection_is_its_own_class(self):
|
||||||
|
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
|
||||||
|
self.assertEqual(result["classification"], LOCAL_VALIDATOR_REJECTION)
|
||||||
|
self.assertTrue(result["pre_api"])
|
||||||
|
|
||||||
|
def test_capability_gate_rejection_is_its_own_class(self):
|
||||||
|
result = classify_mutation_attempt(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"api_called": False,
|
||||||
|
"permission_report": {"missing_permission": "gitea.issue.comment"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], CAPABILITY_GATE_REJECTION)
|
||||||
|
self.assertTrue(result["pre_api"])
|
||||||
|
|
||||||
|
def test_transport_failure_before_api_is_its_own_class(self):
|
||||||
|
result = classify_mutation_attempt(
|
||||||
|
{"success": False, "api_called": False, "transport_error": "EOF"}
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], TRANSPORT_FAILURE_BEFORE_API)
|
||||||
|
self.assertTrue(result["pre_api"])
|
||||||
|
|
||||||
|
def test_successful_server_mutation_is_its_own_class(self):
|
||||||
|
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
|
||||||
|
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
|
||||||
|
self.assertFalse(result["pre_api"])
|
||||||
|
|
||||||
|
def test_each_class_is_distinct(self):
|
||||||
|
classes = {
|
||||||
|
classify_mutation_attempt(payload)["classification"]
|
||||||
|
for payload in (
|
||||||
|
MISSING_LEDGER_BLOCK,
|
||||||
|
{"success": False, "api_called": False, "capability_blocked": True},
|
||||||
|
{"success": False, "api_called": False, "transport_failed": True},
|
||||||
|
SUCCESSFUL_COMMENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertEqual(len(classes), 4)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC2BudgetAccounting(unittest.TestCase):
|
||||||
|
"""AC2: pre-API failures never consume server-side mutation budget."""
|
||||||
|
|
||||||
|
def test_missing_thread_state_ledger_not_counted_as_mutation(self):
|
||||||
|
result = classify_mutation_attempt(MISSING_LEDGER_BLOCK)
|
||||||
|
self.assertFalse(result["budget_consumed"])
|
||||||
|
self.assertIs(result["api_called"], False)
|
||||||
|
|
||||||
|
def test_missing_canonical_issue_state_not_counted_as_mutation(self):
|
||||||
|
result = classify_mutation_attempt(MISSING_CANONICAL_STATE)
|
||||||
|
self.assertFalse(result["budget_consumed"])
|
||||||
|
self.assertIs(result["api_called"], False)
|
||||||
|
|
||||||
|
def test_transport_failure_before_api_not_counted_as_mutation(self):
|
||||||
|
result = classify_mutation_attempt(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"api_called": False,
|
||||||
|
"transport_error": "connection reset",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertFalse(result["budget_consumed"])
|
||||||
|
|
||||||
|
def test_capability_gate_block_not_counted_as_mutation(self):
|
||||||
|
result = classify_mutation_attempt(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"api_called": False,
|
||||||
|
"permission_report": {"missing_permission": "gitea.pr.merge"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertFalse(result["budget_consumed"])
|
||||||
|
|
||||||
|
def test_successful_comment_with_comment_id_counts_as_one_mutation(self):
|
||||||
|
result = classify_mutation_attempt(SUCCESSFUL_COMMENT)
|
||||||
|
self.assertTrue(result["budget_consumed"])
|
||||||
|
self.assertEqual(result["proof_fields"], ["comment_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC2FailsClosed(unittest.TestCase):
|
||||||
|
"""AC2 must not become a loophole: ambiguity still fails closed."""
|
||||||
|
|
||||||
|
def test_api_reached_without_proof_is_ambiguous_and_consumes_budget(self):
|
||||||
|
result = classify_mutation_attempt({"success": True, "api_called": True})
|
||||||
|
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
|
||||||
|
self.assertTrue(result["budget_consumed"])
|
||||||
|
self.assertTrue(result["requires_readback"])
|
||||||
|
|
||||||
|
def test_missing_api_called_signal_fails_closed(self):
|
||||||
|
result = classify_mutation_attempt({"success": False})
|
||||||
|
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
|
||||||
|
self.assertTrue(result["budget_consumed"])
|
||||||
|
self.assertIsNone(result["api_called"])
|
||||||
|
|
||||||
|
def test_empty_and_none_results_fail_closed(self):
|
||||||
|
for payload in ({}, None):
|
||||||
|
result = classify_mutation_attempt(payload)
|
||||||
|
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
|
||||||
|
self.assertTrue(result["budget_consumed"])
|
||||||
|
|
||||||
|
def test_success_with_proof_counts_even_when_api_called_absent(self):
|
||||||
|
result = classify_mutation_attempt({"success": True, "comment_id": 13320})
|
||||||
|
self.assertEqual(result["classification"], SERVER_SIDE_MUTATION)
|
||||||
|
self.assertTrue(result["budget_consumed"])
|
||||||
|
|
||||||
|
def test_blank_proof_field_is_not_proof(self):
|
||||||
|
result = classify_mutation_attempt(
|
||||||
|
{"success": True, "api_called": True, "merge_commit_sha": " "}
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], AMBIGUOUS_REQUIRES_READBACK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC3AttemptLedger(unittest.TestCase):
|
||||||
|
"""AC3: failed attempts are still logged locally."""
|
||||||
|
|
||||||
|
def test_failed_attempts_are_recorded(self):
|
||||||
|
ledger: list[dict] = []
|
||||||
|
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
|
||||||
|
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
|
||||||
|
self.assertEqual(len(ledger), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
all(e["classification"] == LOCAL_VALIDATOR_REJECTION for e in ledger)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recorded_entry_carries_operation_and_timestamp(self):
|
||||||
|
ledger: list[dict] = []
|
||||||
|
entry = record_attempt(
|
||||||
|
ledger,
|
||||||
|
SUCCESSFUL_COMMENT,
|
||||||
|
operation="create_issue_comment",
|
||||||
|
timestamp="2026-07-20T18:15:04+00:00",
|
||||||
|
)
|
||||||
|
self.assertEqual(entry["operation"], "create_issue_comment")
|
||||||
|
self.assertEqual(entry["timestamp"], "2026-07-20T18:15:04+00:00")
|
||||||
|
|
||||||
|
def test_timestamp_is_generated_when_omitted(self):
|
||||||
|
ledger: list[dict] = []
|
||||||
|
entry = record_attempt(ledger, SUCCESSFUL_COMMENT)
|
||||||
|
self.assertTrue(entry["timestamp"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC5CorrectedCommentAllowed(unittest.TestCase):
|
||||||
|
"""AC5: the #615 reproduction — two local rejections then one success."""
|
||||||
|
|
||||||
|
def _replay_615_flow(self) -> list[dict]:
|
||||||
|
ledger: list[dict] = []
|
||||||
|
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="create_issue_comment")
|
||||||
|
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="create_issue_comment")
|
||||||
|
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="create_issue_comment")
|
||||||
|
return ledger
|
||||||
|
|
||||||
|
def test_corrected_comment_after_two_rejections_is_allowed(self):
|
||||||
|
summary = summarize_attempt_ledger(self._replay_615_flow())
|
||||||
|
# The regression: budget must show ONE mutation, not three attempts.
|
||||||
|
self.assertEqual(summary["successful_server_mutations"], 1)
|
||||||
|
self.assertEqual(summary["budget_consumed"], 1)
|
||||||
|
|
||||||
|
def test_all_three_attempts_remain_visible(self):
|
||||||
|
summary = summarize_attempt_ledger(self._replay_615_flow())
|
||||||
|
self.assertEqual(summary["total_attempts"], 3)
|
||||||
|
self.assertEqual(summary["local_failed_attempts"], 2)
|
||||||
|
|
||||||
|
def test_no_readback_required_for_clean_flow(self):
|
||||||
|
summary = summarize_attempt_ledger(self._replay_615_flow())
|
||||||
|
self.assertFalse(summary["requires_readback"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC4FinalReportAccounting(unittest.TestCase):
|
||||||
|
"""AC4: the report must show the three categories, and match the ledger."""
|
||||||
|
|
||||||
|
def _mixed_ledger(self) -> list[dict]:
|
||||||
|
ledger: list[dict] = []
|
||||||
|
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
|
||||||
|
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
|
||||||
|
record_attempt(
|
||||||
|
ledger,
|
||||||
|
{"success": False, "api_called": False, "transport_error": "EOF"},
|
||||||
|
operation="comment",
|
||||||
|
)
|
||||||
|
record_attempt(
|
||||||
|
ledger,
|
||||||
|
{"success": False, "api_called": False, "capability_blocked": True},
|
||||||
|
operation="merge",
|
||||||
|
)
|
||||||
|
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
|
||||||
|
return ledger
|
||||||
|
|
||||||
|
def test_summary_separates_the_three_categories(self):
|
||||||
|
summary = summarize_attempt_ledger(self._mixed_ledger())
|
||||||
|
self.assertEqual(summary["local_failed_attempts"], 2)
|
||||||
|
self.assertEqual(summary["blocked_api_attempts"], 2)
|
||||||
|
self.assertEqual(summary["successful_server_mutations"], 1)
|
||||||
|
|
||||||
|
def test_matching_report_is_valid(self):
|
||||||
|
result = assess_final_report_mutation_accounting(
|
||||||
|
{
|
||||||
|
"local_failed_attempts": 2,
|
||||||
|
"blocked_api_attempts": 2,
|
||||||
|
"successful_server_mutations": 1,
|
||||||
|
},
|
||||||
|
self._mixed_ledger(),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
|
||||||
|
def test_omitted_category_fails_closed(self):
|
||||||
|
result = assess_final_report_mutation_accounting(
|
||||||
|
{"local_failed_attempts": 2, "blocked_api_attempts": 2},
|
||||||
|
self._mixed_ledger(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("successful_server_mutations" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_inflated_mutation_count_fails_closed(self):
|
||||||
|
# The #617 bug shape: claiming three mutations when only one landed.
|
||||||
|
result = assess_final_report_mutation_accounting(
|
||||||
|
{
|
||||||
|
"local_failed_attempts": 2,
|
||||||
|
"blocked_api_attempts": 2,
|
||||||
|
"successful_server_mutations": 3,
|
||||||
|
},
|
||||||
|
self._mixed_ledger(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("successful_server_mutations=3" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ambiguous_attempt_requires_readback_proof(self):
|
||||||
|
ledger: list[dict] = []
|
||||||
|
record_attempt(ledger, {"success": True, "api_called": True}, operation="comment")
|
||||||
|
report = {
|
||||||
|
"local_failed_attempts": 0,
|
||||||
|
"blocked_api_attempts": 0,
|
||||||
|
"successful_server_mutations": 0,
|
||||||
|
}
|
||||||
|
blocked = assess_final_report_mutation_accounting(report, ledger)
|
||||||
|
self.assertFalse(blocked["valid"])
|
||||||
|
self.assertTrue(any("readback_verified" in r for r in blocked["reasons"]))
|
||||||
|
|
||||||
|
allowed = assess_final_report_mutation_accounting(
|
||||||
|
{**report, "readback_verified": True}, ledger
|
||||||
|
)
|
||||||
|
self.assertTrue(allowed["valid"], allowed["reasons"])
|
||||||
|
|
||||||
|
def test_ledger_summary_is_returned_without_raw_entries(self):
|
||||||
|
result = assess_final_report_mutation_accounting({}, self._mixed_ledger())
|
||||||
|
self.assertNotIn("entries", result["ledger_summary"])
|
||||||
|
self.assertEqual(result["ledger_summary"]["total_attempts"], 5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyLedger(unittest.TestCase):
|
||||||
|
def test_empty_ledger_summarizes_to_zero(self):
|
||||||
|
summary = summarize_attempt_ledger([])
|
||||||
|
self.assertEqual(summary["total_attempts"], 0)
|
||||||
|
self.assertEqual(summary["successful_server_mutations"], 0)
|
||||||
|
self.assertFalse(summary["requires_readback"])
|
||||||
|
|
||||||
|
def test_none_ledger_is_tolerated(self):
|
||||||
|
self.assertEqual(summarize_attempt_ledger(None)["total_attempts"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidatorIntegration(unittest.TestCase):
|
||||||
|
"""The classifier is wired into the shared final-report validator (AC4)."""
|
||||||
|
|
||||||
|
def _ledger_two_rejections_one_success(self) -> list[dict]:
|
||||||
|
ledger: list[dict] = []
|
||||||
|
record_attempt(ledger, MISSING_LEDGER_BLOCK, operation="comment")
|
||||||
|
record_attempt(ledger, MISSING_CANONICAL_STATE, operation="comment")
|
||||||
|
record_attempt(ledger, SUCCESSFUL_COMMENT, operation="comment")
|
||||||
|
return ledger
|
||||||
|
|
||||||
|
def test_rule_is_noop_without_a_ledger(self):
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
|
||||||
|
result = assess_final_report_validator("some report", "review_pr")
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
f["rule_id"] == "shared.mutation_budget_accounting"
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_report_matching_ledger_produces_no_finding(self):
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
|
||||||
|
report = (
|
||||||
|
"Local failed attempts: 2\n"
|
||||||
|
"Blocked API attempts: 0\n"
|
||||||
|
"Successful server-side mutations: 1\n"
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"review_pr",
|
||||||
|
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
f["rule_id"] == "shared.mutation_budget_accounting"
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_counting_rejections_as_mutations_is_blocked(self):
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
|
||||||
|
# The #617 bug: three attempts reported as three server-side mutations.
|
||||||
|
report = (
|
||||||
|
"Local failed attempts: 0\n"
|
||||||
|
"Blocked API attempts: 0\n"
|
||||||
|
"Successful server-side mutations: 3\n"
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"review_pr",
|
||||||
|
mutation_attempt_ledger=self._ledger_two_rejections_one_success(),
|
||||||
|
)
|
||||||
|
findings = [
|
||||||
|
f
|
||||||
|
for f in result["findings"]
|
||||||
|
if f["rule_id"] == "shared.mutation_budget_accounting"
|
||||||
|
]
|
||||||
|
self.assertTrue(findings)
|
||||||
|
self.assertTrue(all(f["severity"] == "block" for f in findings))
|
||||||
@@ -224,9 +224,20 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
|||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
return_value={"current_branch": "master"},
|
return_value={"current_branch": "master"},
|
||||||
):
|
):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with mock.patch(
|
||||||
srv.verify_preflight_purity("prgs")
|
"gitea_mcp_server._session_author_lock_worktree",
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
return_value=None,
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv.verify_preflight_purity("prgs")
|
||||||
|
blob = str(ctx.exception)
|
||||||
|
self.assertTrue(
|
||||||
|
"stable control checkout" in blob
|
||||||
|
or "control checkout" in blob
|
||||||
|
or "#618" in blob
|
||||||
|
or "author worktree" in blob.lower(),
|
||||||
|
msg=blob,
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
@mock.patch("subprocess.run")
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import post_merge_moot_lease_gate as moot_gate # noqa: E402
|
||||||
import reviewer_pr_lease as leases # noqa: E402
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
from mcp_server import ( # noqa: E402
|
from mcp_server import ( # noqa: E402
|
||||||
gitea_acquire_reviewer_pr_lease,
|
gitea_acquire_reviewer_pr_lease,
|
||||||
@@ -30,6 +32,13 @@ MERGER_ENV = {
|
|||||||
"GITEA_PROFILE_NAME": "prgs-merger",
|
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
||||||
}
|
}
|
||||||
|
# #745: applying the terminal marker is reconciler-owned.
|
||||||
|
RECONCILER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.close",
|
||||||
|
}
|
||||||
|
CLEANUP_TASK = moot_gate.CLEANUP_TASK
|
||||||
|
REPO_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
PR = 487
|
PR = 487
|
||||||
ISSUE = 485
|
ISSUE = 485
|
||||||
SESSION = "97274-676d20a825c4"
|
SESSION = "97274-676d20a825c4"
|
||||||
@@ -204,6 +213,8 @@ class TestAcquireToolRefusesMergedPR(unittest.TestCase):
|
|||||||
class TestCleanupTool(unittest.TestCase):
|
class TestCleanupTool(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
leases.clear_session_lease()
|
leases.clear_session_lease()
|
||||||
|
moot_gate._reset_for_testing()
|
||||||
|
self.addCleanup(moot_gate._reset_for_testing)
|
||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@@ -223,23 +234,53 @@ class TestCleanupTool(unittest.TestCase):
|
|||||||
self.assertEqual(calls["post"], [])
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
|
@patch("mcp_server._repository_binding_block", return_value=None)
|
||||||
|
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_apply_posts_released_marker_on_merged_pr(
|
def test_apply_posts_released_marker_on_merged_pr(
|
||||||
self, mock_api, _auth, _purity):
|
self, mock_api, _auth, _slug, _binding, _purity):
|
||||||
|
"""#745: apply is reconciler-only and needs a matching dry run first."""
|
||||||
side, calls = _api_side_effect(
|
side, calls = _api_side_effect(
|
||||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
mock_api.side_effect = side
|
mock_api.side_effect = side
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
with patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CLEANUP_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
result = gitea_cleanup_post_merge_moot_lease(
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
pr_number=PR, apply=True, remote="prgs")
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
self.assertTrue(result["cleanup_performed"])
|
self.assertTrue(result["cleanup_performed"])
|
||||||
self.assertEqual(result["released_comment_id"], 9999)
|
self.assertEqual(result["released_comment_id"], 9999)
|
||||||
self.assertEqual(len(calls["post"]), 1)
|
self.assertEqual(len(calls["post"]), 1)
|
||||||
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
||||||
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
||||||
|
|
||||||
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
|
@patch("mcp_server._repository_binding_block", return_value=None)
|
||||||
|
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_merger_can_no_longer_apply(
|
||||||
|
self, mock_api, _auth, _slug, _binding, _purity):
|
||||||
|
"""#745: holding gitea.pr.comment is no longer sufficient to apply."""
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
|
mock_api.side_effect = side
|
||||||
|
with patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CLEANUP_TASK), \
|
||||||
|
patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|||||||
srv._preflight_capability_violation = False
|
srv._preflight_capability_violation = False
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
srv._preflight_in_test_mode = lambda: False
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
self._orig_resolved_task = srv._preflight_resolved_task
|
||||||
|
self._orig_resolved_role = srv._preflight_resolved_role
|
||||||
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
|
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
|
||||||
self._env_patch.start()
|
self._env_patch.start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
|
# Preflight task/role are module-level; restore so test order cannot
|
||||||
|
# leak a resolved task into sibling cases.
|
||||||
|
srv._preflight_resolved_task = self._orig_resolved_task
|
||||||
|
srv._preflight_resolved_role = self._orig_resolved_role
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@@ -81,26 +87,28 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
return_value={"current_branch": "master"},
|
return_value={"current_branch": "master"},
|
||||||
)
|
)
|
||||||
def test_author_create_issue_still_blocked_on_control_checkout(
|
def test_author_non_create_issue_still_blocked_on_control_checkout(
|
||||||
self, _git, _get_all, _role, _ns, _prof, _auth
|
self, _git, _get_all, _role, _ns, _prof, _auth
|
||||||
):
|
):
|
||||||
|
"""Author mutations other than create_issue keep the branches-only rule.
|
||||||
|
|
||||||
|
#749/#750 sanctioned ``create_issue`` from a clean control checkout, and
|
||||||
|
#757 made the #604 anti-stomp guard honour that same decision — so
|
||||||
|
``create_issue`` is no longer a valid probe for this boundary. This case
|
||||||
|
previously asserted create_issue stayed blocked, which only held because
|
||||||
|
the bootstrap-blind #604 guard was overriding #750; that is precisely
|
||||||
|
the defect #757 fixed. ``lock_issue`` is issue-backed and post-ownership,
|
||||||
|
so it still requires a ``branches/`` worktree.
|
||||||
|
"""
|
||||||
srv._preflight_resolved_role = "author"
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_resolved_task = "lock_issue"
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
try:
|
try:
|
||||||
res = srv.gitea_create_issue(title="Test", body="body")
|
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
self.assertIn("stable control checkout", str(exc))
|
self.assertIn("control checkout", str(exc).lower())
|
||||||
else:
|
else:
|
||||||
# #683 typed blocker at mutation entrypoint
|
self.fail("lock_issue must stay blocked on the control checkout")
|
||||||
self.assertFalse(res.get("success"))
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -242,6 +242,32 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertTrue(res.get("stop_required"))
|
self.assertTrue(res.get("stop_required"))
|
||||||
self.assertIs(res.get("mutation_performed"), False)
|
self.assertIs(res.get("mutation_performed"), False)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
def test_denied_role_exclusive_resolution_does_not_stamp_role(
|
||||||
|
self, _auth, _api
|
||||||
|
):
|
||||||
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"record_preflight_check",
|
||||||
|
wraps=mcp_server.record_preflight_check,
|
||||||
|
) as record:
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="review_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["allowed_in_current_session"], result)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
len(call.args) > 1 and call.args[1] == "reviewer"
|
||||||
|
for call in record.call_args_list
|
||||||
|
),
|
||||||
|
"denied reviewer resolution must never record a reviewer stamp",
|
||||||
|
)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||||
|
|
||||||
# Additional regression tests per #145 for permission boundaries and structured guidance
|
# Additional regression tests per #145 for permission boundaries and structured guidance
|
||||||
def test_issue_comment_does_not_imply_close(self):
|
def test_issue_comment_does_not_imply_close(self):
|
||||||
# Author profile has issue.comment but not issue.close
|
# Author profile has issue.comment but not issue.close
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"""Tests for self-propagating canonical handoffs (#626)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||||
|
from self_propagating_handoff import ( # noqa: E402
|
||||||
|
HANDOFF_FIELDS,
|
||||||
|
NEXT_ACTOR_BY_STATE,
|
||||||
|
WORKFLOW_STATES,
|
||||||
|
assess_controller_decision,
|
||||||
|
assess_durable_state_update,
|
||||||
|
assess_final_report_self_propagating_handoff,
|
||||||
|
assess_handoff_live_state,
|
||||||
|
assess_merge_completion_transition,
|
||||||
|
assess_role_continuation,
|
||||||
|
assess_self_propagating_handoff,
|
||||||
|
assess_thread_recoverability,
|
||||||
|
assess_workflow_failure_escalation,
|
||||||
|
parse_self_propagating_handoff,
|
||||||
|
render_self_propagating_handoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
|
||||||
|
AUTHOR_PROMPT = (
|
||||||
|
"Review PR #900 on Scaled-Tech-Consulting/Gitea-Tools for issue 626 at head "
|
||||||
|
"aaaa111. Validate the branch, then submit an independent review verdict."
|
||||||
|
)
|
||||||
|
REVIEWER_PROMPT = (
|
||||||
|
"Merge PR #900 on Scaled-Tech-Consulting/Gitea-Tools for issue 626 once the "
|
||||||
|
"approval at head aaaa111 still applies to the live head."
|
||||||
|
)
|
||||||
|
MERGER_PROMPT = (
|
||||||
|
"Accept or reject the merged work for issue 626 on "
|
||||||
|
"Scaled-Tech-Consulting/Gitea-Tools; verify acceptance criteria then close."
|
||||||
|
)
|
||||||
|
CONTROLLER_PROMPT = (
|
||||||
|
"Address the controller's requested changes for issue 626 on "
|
||||||
|
"Scaled-Tech-Consulting/Gitea-Tools, then hand back to an independent reviewer."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_handoff(**overrides):
|
||||||
|
"""Render a valid author -> reviewer handoff, with overrides applied."""
|
||||||
|
values = {
|
||||||
|
"REPOSITORY": REPO,
|
||||||
|
"ISSUE": "626",
|
||||||
|
"PR": "900",
|
||||||
|
"WORKFLOW_STATE": "needs-review",
|
||||||
|
"HEAD_SHA": "aaaa111",
|
||||||
|
"BASE_BRANCH": "master",
|
||||||
|
"BASE_OR_MERGE_SHA": "bbbb222",
|
||||||
|
"ACTING_ROLE": "author",
|
||||||
|
"ACTING_IDENTITY": "jcwalker3 (prgs-author)",
|
||||||
|
"COMPLETED_ACTIONS": "implemented AC1-AC9; opened PR #900",
|
||||||
|
"VALIDATION_EVIDENCE": "pytest tests/test_self_propagating_handoff.py: 20 passed",
|
||||||
|
"MUTATION_LEDGER": "branch pushed; PR #900 opened; comment 13547 posted",
|
||||||
|
"BLOCKERS": "none",
|
||||||
|
"NEXT_ACTOR": "reviewer",
|
||||||
|
"NEXT_ACTION": "independently review PR #900 at head aaaa111",
|
||||||
|
"PROHIBITED_ACTIONS": "merge, self-approve, force-push",
|
||||||
|
"NEXT_PROMPT": AUTHOR_PROMPT,
|
||||||
|
"WORKFLOW_FAILURE_ISSUES": "none",
|
||||||
|
"LAST_UPDATED": "2026-07-21T03:55:00Z",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return render_self_propagating_handoff(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderAndParseTests(unittest.TestCase):
|
||||||
|
def test_render_emits_every_canonical_field(self):
|
||||||
|
body = build_handoff()
|
||||||
|
parsed = parse_self_propagating_handoff(body)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
for name in HANDOFF_FIELDS:
|
||||||
|
self.assertIn(name, parsed)
|
||||||
|
|
||||||
|
def test_render_rejects_unknown_workflow_state(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_handoff(WORKFLOW_STATE="almost-done")
|
||||||
|
|
||||||
|
def test_every_state_maps_to_exactly_one_actor(self):
|
||||||
|
self.assertEqual(set(WORKFLOW_STATES), set(NEXT_ACTOR_BY_STATE))
|
||||||
|
|
||||||
|
def test_absent_block_parses_as_none(self):
|
||||||
|
self.assertIsNone(parse_self_propagating_handoff("no handoff here"))
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorToReviewerTests(unittest.TestCase):
|
||||||
|
"""Scenario 1: author -> reviewer."""
|
||||||
|
|
||||||
|
def test_valid_author_handoff_passes(self):
|
||||||
|
result = assess_self_propagating_handoff(build_handoff())
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
self.assertEqual(result["next_actor"], "reviewer")
|
||||||
|
self.assertFalse(result["terminal"])
|
||||||
|
|
||||||
|
def test_reviewer_may_continue_author_handoff(self):
|
||||||
|
result = assess_role_continuation(
|
||||||
|
handoff=build_handoff(), actor_role="reviewer"
|
||||||
|
)
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
self.assertIn("review", result["allowed_actions"])
|
||||||
|
|
||||||
|
def test_author_may_not_continue_its_own_handoff(self):
|
||||||
|
result = assess_role_continuation(
|
||||||
|
handoff=build_handoff(), actor_role="author"
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["expected_actor"], "reviewer")
|
||||||
|
|
||||||
|
def test_next_actor_must_match_declared_state(self):
|
||||||
|
result = assess_self_propagating_handoff(
|
||||||
|
build_handoff(NEXT_ACTOR="merger")
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not match state" in reason for reason in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewerToMergerTests(unittest.TestCase):
|
||||||
|
"""Scenario 2: reviewer -> merger."""
|
||||||
|
|
||||||
|
def build(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"WORKFLOW_STATE": "approved-awaiting-merge",
|
||||||
|
"ACTING_ROLE": "reviewer",
|
||||||
|
"ACTING_IDENTITY": "reviewer-bot (prgs-reviewer)",
|
||||||
|
"COMPLETED_ACTIONS": "review 500 APPROVED at aaaa111",
|
||||||
|
"NEXT_ACTOR": "merger",
|
||||||
|
"NEXT_ACTION": "merge PR #900 at approved head aaaa111",
|
||||||
|
"PROHIBITED_ACTIONS": "re-review, commit, push",
|
||||||
|
"NEXT_PROMPT": REVIEWER_PROMPT,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return build_handoff(**values)
|
||||||
|
|
||||||
|
def test_reviewer_handoff_is_valid(self):
|
||||||
|
result = assess_self_propagating_handoff(self.build())
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
self.assertEqual(result["next_actor"], "merger")
|
||||||
|
|
||||||
|
def test_merger_may_continue(self):
|
||||||
|
result = assess_role_continuation(handoff=self.build(), actor_role="merger")
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
self.assertIn("merge", result["allowed_actions"])
|
||||||
|
|
||||||
|
|
||||||
|
class MergerToControllerTests(unittest.TestCase):
|
||||||
|
"""Scenario 3: merger -> controller."""
|
||||||
|
|
||||||
|
def test_merge_success_stops_at_controller_boundary(self):
|
||||||
|
result = assess_merge_completion_transition(merge_succeeded=True)
|
||||||
|
self.assertEqual(result["next_state"], "merged-awaiting-controller")
|
||||||
|
self.assertEqual(result["next_actor"], "controller")
|
||||||
|
self.assertTrue(result["next_prompt_required"])
|
||||||
|
|
||||||
|
def test_configured_auto_accept_may_complete(self):
|
||||||
|
result = assess_merge_completion_transition(
|
||||||
|
merge_succeeded=True, controller_auto_accept=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["next_state"], "complete")
|
||||||
|
self.assertFalse(result["next_prompt_required"])
|
||||||
|
|
||||||
|
def test_failed_merge_keeps_the_work_item_with_the_merger(self):
|
||||||
|
result = assess_merge_completion_transition(merge_succeeded=False)
|
||||||
|
self.assertEqual(result["next_state"], "approved-awaiting-merge")
|
||||||
|
|
||||||
|
def test_merger_handoff_names_the_controller(self):
|
||||||
|
body = build_handoff(
|
||||||
|
WORKFLOW_STATE="merged-awaiting-controller",
|
||||||
|
ACTING_ROLE="merger",
|
||||||
|
ACTING_IDENTITY="merger-bot (prgs-merger)",
|
||||||
|
COMPLETED_ACTIONS="merged PR #900 as cccc333",
|
||||||
|
BASE_OR_MERGE_SHA="cccc333",
|
||||||
|
NEXT_ACTOR="controller",
|
||||||
|
NEXT_ACTION="verify acceptance criteria and close issue 626",
|
||||||
|
PROHIBITED_ACTIONS="reopen the PR, re-merge",
|
||||||
|
NEXT_PROMPT=MERGER_PROMPT,
|
||||||
|
)
|
||||||
|
result = assess_self_propagating_handoff(body)
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
self.assertEqual(result["next_actor"], "controller")
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerBackToAuthorTests(unittest.TestCase):
|
||||||
|
"""Scenario 4: controller -> author."""
|
||||||
|
|
||||||
|
def test_request_corrections_returns_to_author(self):
|
||||||
|
result = assess_controller_decision(decision="request_corrections")
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["next_state"], "needs-author")
|
||||||
|
self.assertTrue(result["next_prompt_required"])
|
||||||
|
|
||||||
|
def test_return_to_actor_requires_a_named_target(self):
|
||||||
|
result = assess_controller_decision(decision="return_to_actor")
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_return_to_reviewer_is_supported(self):
|
||||||
|
result = assess_controller_decision(
|
||||||
|
decision="return_to_actor", return_to="reviewer"
|
||||||
|
)
|
||||||
|
self.assertEqual(result["next_state"], "needs-review")
|
||||||
|
|
||||||
|
def test_unknown_decision_fails_closed(self):
|
||||||
|
result = assess_controller_decision(decision="looks-fine")
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_controller_handoff_back_to_author_validates(self):
|
||||||
|
body = build_handoff(
|
||||||
|
WORKFLOW_STATE="needs-author",
|
||||||
|
ACTING_ROLE="controller",
|
||||||
|
ACTING_IDENTITY="controller (operator)",
|
||||||
|
COMPLETED_ACTIONS="reviewed merged work; requested corrections",
|
||||||
|
NEXT_ACTOR="author",
|
||||||
|
NEXT_ACTION="address controller corrections on issue 626",
|
||||||
|
PROHIBITED_ACTIONS="close the issue, merge",
|
||||||
|
NEXT_PROMPT=CONTROLLER_PROMPT,
|
||||||
|
)
|
||||||
|
result = assess_self_propagating_handoff(body)
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class StaleHeadRejectionTests(unittest.TestCase):
|
||||||
|
"""Scenario 5: stale-head rejection."""
|
||||||
|
|
||||||
|
def test_changed_head_invalidates_a_merge_handoff(self):
|
||||||
|
body = build_handoff(
|
||||||
|
WORKFLOW_STATE="approved-awaiting-merge",
|
||||||
|
ACTING_ROLE="reviewer",
|
||||||
|
NEXT_ACTOR="merger",
|
||||||
|
NEXT_ACTION="merge PR #900 at approved head aaaa111",
|
||||||
|
NEXT_PROMPT=REVIEWER_PROMPT,
|
||||||
|
)
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=body,
|
||||||
|
live={"pr_head_sha": "dddd444", "pr_state": "open"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("changed_pr_head", result["kinds"])
|
||||||
|
self.assertEqual(result["recovered_state"], "needs-review")
|
||||||
|
|
||||||
|
def test_stale_approval_blocks_the_merger(self):
|
||||||
|
body = build_handoff(
|
||||||
|
WORKFLOW_STATE="approved-awaiting-merge",
|
||||||
|
NEXT_ACTOR="merger",
|
||||||
|
NEXT_ACTION="merge PR #900",
|
||||||
|
HEAD_SHA="dddd444",
|
||||||
|
NEXT_PROMPT=REVIEWER_PROMPT,
|
||||||
|
)
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=body,
|
||||||
|
live={"pr_head_sha": "dddd444", "approved_head_sha": "aaaa111"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("stale_approval", result["kinds"])
|
||||||
|
|
||||||
|
def test_unchanged_head_is_not_blocked(self):
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=build_handoff(),
|
||||||
|
live={
|
||||||
|
"pr_head_sha": "aaaa111",
|
||||||
|
"pr_state": "open",
|
||||||
|
"issue_state": "open",
|
||||||
|
"base_branch": "master",
|
||||||
|
"namespace_role": "reviewer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"], result["reasons"])
|
||||||
|
|
||||||
|
def test_merged_pr_recovers_to_the_controller_boundary(self):
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=build_handoff(),
|
||||||
|
live={"pr_head_sha": "aaaa111", "pr_state": "merged"},
|
||||||
|
)
|
||||||
|
self.assertIn("pr_merged", result["kinds"])
|
||||||
|
self.assertEqual(result["recovered_state"], "merged-awaiting-controller")
|
||||||
|
|
||||||
|
def test_reopened_issue_invalidates_a_complete_handoff(self):
|
||||||
|
body = build_handoff(
|
||||||
|
WORKFLOW_STATE="complete",
|
||||||
|
ACTING_ROLE="controller",
|
||||||
|
NEXT_ACTOR="none",
|
||||||
|
NEXT_ACTION="none",
|
||||||
|
NEXT_PROMPT="none",
|
||||||
|
)
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=body, live={"issue_state": "open"}
|
||||||
|
)
|
||||||
|
self.assertIn("issue_reopened", result["kinds"])
|
||||||
|
self.assertEqual(result["recovered_state"], "needs-author")
|
||||||
|
|
||||||
|
def test_foreign_lease_and_worktree_faults_are_detected(self):
|
||||||
|
result = assess_handoff_live_state(
|
||||||
|
handoff=build_handoff(),
|
||||||
|
live={
|
||||||
|
"pr_head_sha": "aaaa111",
|
||||||
|
"lease": {"status": "expired", "session_id": "other-session"},
|
||||||
|
"actor_session_id": "my-session",
|
||||||
|
"worktree": {"present": False, "dirty": True},
|
||||||
|
"namespace_role": "author",
|
||||||
|
"runtime_stale": True,
|
||||||
|
"base_branch": "dev",
|
||||||
|
"conflicting_canonical_comments": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for kind in (
|
||||||
|
"stale_lease",
|
||||||
|
"foreign_lease",
|
||||||
|
"missing_worktree",
|
||||||
|
"dirty_worktree",
|
||||||
|
"namespace_mismatch",
|
||||||
|
"stale_runtime",
|
||||||
|
"changed_base",
|
||||||
|
"conflicting_canonical_comments",
|
||||||
|
):
|
||||||
|
self.assertIn(kind, result["kinds"])
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedInfrastructurePathTests(unittest.TestCase):
|
||||||
|
"""Scenario 6: blocked infrastructure path."""
|
||||||
|
|
||||||
|
def build(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"WORKFLOW_STATE": "blocked",
|
||||||
|
"PR": "none",
|
||||||
|
"HEAD_SHA": "none",
|
||||||
|
"ACTING_ROLE": "author",
|
||||||
|
"COMPLETED_ACTIONS": "attempted native publish; MCP mutation rejected",
|
||||||
|
"BLOCKERS": "gitea_create_pr rejected: namespace unreachable",
|
||||||
|
"NEXT_ACTOR": "operator",
|
||||||
|
"NEXT_ACTION": "restore the author MCP namespace",
|
||||||
|
"PROHIBITED_ACTIONS": "raw git push, curl, force-push",
|
||||||
|
"NEXT_PROMPT": (
|
||||||
|
"Repair the author MCP namespace for "
|
||||||
|
"Scaled-Tech-Consulting/Gitea-Tools so issue 626 can publish "
|
||||||
|
"natively, then hand back to the author."
|
||||||
|
),
|
||||||
|
"WORKFLOW_FAILURE_ISSUES": "#640",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return build_handoff(**values)
|
||||||
|
|
||||||
|
def test_blocked_handoff_without_pr_is_valid(self):
|
||||||
|
result = assess_self_propagating_handoff(self.build())
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
self.assertEqual(result["next_actor"], "operator")
|
||||||
|
|
||||||
|
def test_blocked_requires_a_concrete_blocker(self):
|
||||||
|
result = assess_self_propagating_handoff(self.build(BLOCKERS="none"))
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("BLOCKERS" in reason for reason in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_is_the_only_authorized_continuation(self):
|
||||||
|
self.assertTrue(
|
||||||
|
assess_role_continuation(handoff=self.build(), actor_role="operator")[
|
||||||
|
"allowed"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
assess_role_continuation(handoff=self.build(), actor_role="merger")["block"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalClosureTests(unittest.TestCase):
|
||||||
|
"""Scenario 7: final successful closure."""
|
||||||
|
|
||||||
|
def build(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"WORKFLOW_STATE": "complete",
|
||||||
|
"ACTING_ROLE": "controller",
|
||||||
|
"ACTING_IDENTITY": "controller (operator)",
|
||||||
|
"COMPLETED_ACTIONS": "verified acceptance criteria; closed issue 626",
|
||||||
|
"BASE_OR_MERGE_SHA": "cccc333",
|
||||||
|
"NEXT_ACTOR": "none",
|
||||||
|
"NEXT_ACTION": "none",
|
||||||
|
"PROHIBITED_ACTIONS": "reopen without new evidence",
|
||||||
|
"NEXT_PROMPT": "none",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return build_handoff(**values)
|
||||||
|
|
||||||
|
def test_terminal_handoff_is_valid_without_a_next_prompt(self):
|
||||||
|
result = assess_self_propagating_handoff(self.build())
|
||||||
|
self.assertTrue(result["valid"], result["reasons"])
|
||||||
|
self.assertTrue(result["terminal"])
|
||||||
|
|
||||||
|
def test_terminal_handoff_must_not_manufacture_more_work(self):
|
||||||
|
result = assess_self_propagating_handoff(
|
||||||
|
self.build(NEXT_PROMPT=CONTROLLER_PROMPT)
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("must not carry a NEXT_PROMPT" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_role_may_continue_a_complete_workflow(self):
|
||||||
|
result = assess_role_continuation(handoff=self.build(), actor_role="author")
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_controller_acceptance_requires_full_closure_proof(self):
|
||||||
|
partial = assess_controller_decision(
|
||||||
|
decision="accept",
|
||||||
|
closure_proof={"acceptance_criteria_satisfied": True},
|
||||||
|
)
|
||||||
|
self.assertTrue(partial["block"])
|
||||||
|
self.assertEqual(partial["next_state"], "merged-awaiting-controller")
|
||||||
|
|
||||||
|
full = assess_controller_decision(
|
||||||
|
decision="accept",
|
||||||
|
closure_proof={
|
||||||
|
"acceptance_criteria_satisfied": True,
|
||||||
|
"cleanup_complete": True,
|
||||||
|
"canonical_final_state_posted": True,
|
||||||
|
"issue_closed_through_workflow": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(full["block"])
|
||||||
|
self.assertEqual(full["next_state"], "complete")
|
||||||
|
self.assertFalse(full["next_prompt_required"])
|
||||||
|
|
||||||
|
|
||||||
|
class IncompleteHandoffRejectionTests(unittest.TestCase):
|
||||||
|
"""Scenario 8: incomplete handoff rejection."""
|
||||||
|
|
||||||
|
def test_missing_block_is_rejected(self):
|
||||||
|
result = assess_self_propagating_handoff("Work is done, ping the reviewer.")
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["present"])
|
||||||
|
|
||||||
|
def test_missing_field_is_rejected(self):
|
||||||
|
body = build_handoff()
|
||||||
|
body = "\n".join(
|
||||||
|
line for line in body.splitlines() if not line.startswith("MUTATION_LEDGER:")
|
||||||
|
)
|
||||||
|
result = assess_self_propagating_handoff(body)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("MUTATION_LEDGER", result["missing_fields"])
|
||||||
|
|
||||||
|
def test_placeholder_field_is_rejected(self):
|
||||||
|
result = assess_self_propagating_handoff(
|
||||||
|
build_handoff(VALIDATION_EVIDENCE="TBD")
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_stub_next_prompt_is_rejected(self):
|
||||||
|
result = assess_self_propagating_handoff(build_handoff(NEXT_PROMPT="review it"))
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("ready-to-run" in reason for reason in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prompt_depending_on_outside_chat_is_rejected(self):
|
||||||
|
prompt = (
|
||||||
|
"Continue issue 626 on Scaled-Tech-Consulting/Gitea-Tools using the "
|
||||||
|
"previous chat for the missing details."
|
||||||
|
)
|
||||||
|
result = assess_thread_recoverability(build_handoff(NEXT_PROMPT=prompt))
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_prompt_must_name_repository_and_issue(self):
|
||||||
|
prompt = (
|
||||||
|
"Please review the pull request at the current head and submit an "
|
||||||
|
"independent verdict when validation passes."
|
||||||
|
)
|
||||||
|
result = assess_thread_recoverability(build_handoff(NEXT_PROMPT=prompt))
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_self_contained_prompt_is_recoverable(self):
|
||||||
|
self.assertFalse(assess_thread_recoverability(build_handoff())["block"])
|
||||||
|
|
||||||
|
def test_chat_only_report_is_not_durable(self):
|
||||||
|
result = assess_durable_state_update(
|
||||||
|
handoff_text=build_handoff(),
|
||||||
|
posted_comment_id=None,
|
||||||
|
canonical_state_posted=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(len(result["reasons"]), 2)
|
||||||
|
|
||||||
|
def test_posted_handoff_is_durable(self):
|
||||||
|
result = assess_durable_state_update(
|
||||||
|
handoff_text=build_handoff(),
|
||||||
|
posted_comment_id=13550,
|
||||||
|
canonical_state_posted=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["durable"], result["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowFailureEscalationTests(unittest.TestCase):
|
||||||
|
"""Scenario 9: duplicate workflow-failure issue handling."""
|
||||||
|
|
||||||
|
def failure(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"signature": "lease-cleanup-internal-error",
|
||||||
|
"classification": "mcp-tool-defect",
|
||||||
|
"linked_issue": "718",
|
||||||
|
"temporary_impact": "lease cleanup unavailable this session",
|
||||||
|
"next_valid_actor": "operator",
|
||||||
|
"recovery_prompt": "restart the namespace and re-run lease cleanup",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def test_complete_failure_record_passes(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure()], active_issue_number=626
|
||||||
|
)
|
||||||
|
self.assertTrue(result["escalated"], result["reasons"])
|
||||||
|
|
||||||
|
def test_incomplete_failure_record_fails_closed(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure(recovery_prompt="")], active_issue_number=626
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_folding_into_the_active_issue_is_rejected(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure(linked_issue="626")], active_issue_number=626
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("folded into the active work item" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_known_signature_reuses_the_existing_issue(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure()],
|
||||||
|
active_issue_number=626,
|
||||||
|
existing_failure_issues=[
|
||||||
|
{"signature": "lease-cleanup-internal-error", "number": 718}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["escalated"], result["reasons"])
|
||||||
|
self.assertEqual(result["reused_issues"], [
|
||||||
|
{"signature": "lease-cleanup-internal-error", "issue": "718"}
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_duplicate_issue_for_known_signature_is_rejected(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure(linked_issue="799")],
|
||||||
|
active_issue_number=626,
|
||||||
|
existing_failure_issues=[
|
||||||
|
{"signature": "lease-cleanup-internal-error", "number": 718}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("reuse the existing issue #718" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_signature_twice_in_one_session_is_rejected(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[self.failure(), self.failure()], active_issue_number=626
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_no_failures_is_not_an_error(self):
|
||||||
|
result = assess_workflow_failure_escalation(
|
||||||
|
failures=[], active_issue_number=626
|
||||||
|
)
|
||||||
|
self.assertTrue(result["escalated"])
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReportIntegrationTests(unittest.TestCase):
|
||||||
|
def test_report_without_the_protocol_is_not_applicable(self):
|
||||||
|
result = assess_final_report_self_propagating_handoff("## Controller Handoff\n")
|
||||||
|
self.assertFalse(result["applicable"])
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_report_with_a_complete_handoff_passes(self):
|
||||||
|
result = assess_final_report_self_propagating_handoff(build_handoff())
|
||||||
|
self.assertTrue(result["applicable"])
|
||||||
|
self.assertFalse(result["block"], result["reasons"])
|
||||||
|
|
||||||
|
def test_report_with_an_incomplete_handoff_blocks(self):
|
||||||
|
result = assess_final_report_self_propagating_handoff(
|
||||||
|
build_handoff(NEXT_ACTION="")
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_validator_blocks_an_incomplete_handoff_in_a_work_issue_report(self):
|
||||||
|
report = build_handoff(MUTATION_LEDGER="TBD")
|
||||||
|
result = assess_final_report_validator(report, "work_issue")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
finding["rule_id"] == "shared.self_propagating_handoff"
|
||||||
|
for finding in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validator_ignores_reports_that_predate_the_protocol(self):
|
||||||
|
result = assess_final_report_validator("plain legacy report", "work_issue")
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
finding["rule_id"] == "shared.self_propagating_handoff"
|
||||||
|
for finding in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
"""Tests for the Sentry → Gitea incident bridge (#607).
|
||||||
|
|
||||||
|
Covers AC9: create, update, dedupe, closed-linked issue, redaction,
|
||||||
|
pagination, missing token, unavailable Sentry server, and self-hosted base URL.
|
||||||
|
|
||||||
|
No live Sentry: the HTTP layer is injected via ``http_fn``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
from incident_bridge import (
|
||||||
|
OUTCOME_CREATED,
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
OUTCOME_UPDATED,
|
||||||
|
ProjectMapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
import sentry_incident_bridge as bridge
|
||||||
|
|
||||||
|
BASE_URL = "https://sentry.prgs.cc"
|
||||||
|
SENTRY_ORG = "prgs"
|
||||||
|
SENTRY_PROJECT = "gitea-tools-mcp"
|
||||||
|
GITEA_ORG = "Scaled-Tech-Consulting"
|
||||||
|
GITEA_REPO = "Gitea-Tools"
|
||||||
|
TOKEN = "synthetic-test-token"
|
||||||
|
|
||||||
|
|
||||||
|
def _config(**kwargs) -> bridge.SentryBridgeConfig:
|
||||||
|
base = dict(
|
||||||
|
base_url=BASE_URL,
|
||||||
|
org=SENTRY_ORG,
|
||||||
|
project=SENTRY_PROJECT,
|
||||||
|
lookback="24h",
|
||||||
|
min_events_for_issue=2,
|
||||||
|
bridge_enabled=True,
|
||||||
|
)
|
||||||
|
base.update(kwargs)
|
||||||
|
return bridge.SentryBridgeConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping() -> ProjectMapping:
|
||||||
|
return ProjectMapping(
|
||||||
|
name="gitea-tools-mcp",
|
||||||
|
provider="sentry",
|
||||||
|
monitor_base_url=BASE_URL,
|
||||||
|
monitor_org=SENTRY_ORG,
|
||||||
|
monitor_project=SENTRY_PROJECT,
|
||||||
|
gitea_org=GITEA_ORG,
|
||||||
|
gitea_repo=GITEA_REPO,
|
||||||
|
default_labels=("type:bug", "observability", "sentry", "status:ready"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_issue(issue_id: str = "4001", **kwargs) -> dict:
|
||||||
|
payload = {
|
||||||
|
"id": issue_id,
|
||||||
|
"shortId": "GITEA-TOOLS-1A",
|
||||||
|
"title": "RuntimeError: lease acquisition failed",
|
||||||
|
"culprit": "lease_lifecycle in acquire",
|
||||||
|
"level": "error",
|
||||||
|
"status": "unresolved",
|
||||||
|
"count": "7",
|
||||||
|
"userCount": 1,
|
||||||
|
"firstSeen": "2026-07-18T04:11:02.000000Z",
|
||||||
|
"lastSeen": "2026-07-19T22:40:17.000000Z",
|
||||||
|
"permalink": f"{BASE_URL}/organizations/{SENTRY_ORG}/issues/{issue_id}/",
|
||||||
|
"metadata": {"type": "RuntimeError", "value": "lease acquisition failed"},
|
||||||
|
}
|
||||||
|
payload.update(kwargs)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_event(event_id: str = "ev-1", **kwargs) -> dict:
|
||||||
|
payload = {
|
||||||
|
"eventID": event_id,
|
||||||
|
"message": "lease acquisition failed",
|
||||||
|
"dateCreated": "2026-07-19T22:40:17.000000Z",
|
||||||
|
"platform": "python",
|
||||||
|
"environment": "prod",
|
||||||
|
"release": "1.2.3",
|
||||||
|
"tags": [{"key": "role", "value": "author"}],
|
||||||
|
}
|
||||||
|
payload.update(kwargs)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
"""Routes synthetic Sentry responses and records requested URLs."""
|
||||||
|
|
||||||
|
def __init__(self, routes: list[tuple[int, object, dict[str, str]]] | None = None):
|
||||||
|
# routes: sequential responses for the issues endpoint
|
||||||
|
self.routes = routes or []
|
||||||
|
self.calls: list[str] = []
|
||||||
|
self.headers_seen: list[dict[str, str]] = []
|
||||||
|
self.issue_page = 0
|
||||||
|
|
||||||
|
def __call__(self, url, headers, timeout):
|
||||||
|
self.calls.append(url)
|
||||||
|
self.headers_seen.append(dict(headers))
|
||||||
|
if "/events/" in url:
|
||||||
|
return 200, json.dumps([_raw_event()]).encode(), {}
|
||||||
|
if self.routes:
|
||||||
|
index = min(self.issue_page, len(self.routes) - 1)
|
||||||
|
self.issue_page += 1
|
||||||
|
status, payload, resp_headers = self.routes[index]
|
||||||
|
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
|
||||||
|
return status, body, resp_headers
|
||||||
|
return 200, json.dumps([_raw_issue()]).encode(), {}
|
||||||
|
|
||||||
|
|
||||||
|
class SentryBridgeTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||||
|
self.db = ControlPlaneDB(self.db_path)
|
||||||
|
self.created: list[dict] = []
|
||||||
|
self.comments: list[dict] = []
|
||||||
|
self._next_issue_number = 900
|
||||||
|
self._next_comment_id = 5000
|
||||||
|
|
||||||
|
def _create_issue_fn(self):
|
||||||
|
def create_fn(title, body, labels, g_org, g_repo):
|
||||||
|
self._next_issue_number += 1
|
||||||
|
self.created.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"labels": list(labels),
|
||||||
|
"org": g_org,
|
||||||
|
"repo": g_repo,
|
||||||
|
"number": self._next_issue_number,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"success": True, "number": self._next_issue_number}
|
||||||
|
|
||||||
|
return create_fn
|
||||||
|
|
||||||
|
def _comment_issue_fn(self):
|
||||||
|
def comment_fn(issue_number, body, g_org, g_repo):
|
||||||
|
self._next_comment_id += 1
|
||||||
|
self.comments.append(
|
||||||
|
{
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"body": body,
|
||||||
|
"org": g_org,
|
||||||
|
"repo": g_repo,
|
||||||
|
"comment_id": self._next_comment_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"success": True, "comment_id": self._next_comment_id}
|
||||||
|
|
||||||
|
return comment_fn
|
||||||
|
|
||||||
|
def _link(self, issue_id: str = "4001"):
|
||||||
|
return self.db.get_incident_link_by_provider(
|
||||||
|
provider="sentry",
|
||||||
|
provider_issue_id=issue_id,
|
||||||
|
provider_base_url=BASE_URL,
|
||||||
|
provider_org=SENTRY_ORG,
|
||||||
|
provider_project=SENTRY_PROJECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _watchdog(self, http, *, apply=True, config=None, **kwargs):
|
||||||
|
return bridge.watchdog(
|
||||||
|
self.db,
|
||||||
|
config or _config(),
|
||||||
|
token=TOKEN,
|
||||||
|
apply=apply,
|
||||||
|
mappings=[_mapping()],
|
||||||
|
http_fn=http,
|
||||||
|
create_issue_fn=self._create_issue_fn(),
|
||||||
|
# Always supplied, including dry runs: the bridge itself must
|
||||||
|
# withhold the comment when apply=False (AC4 + AC8).
|
||||||
|
comment_issue_fn=self._comment_issue_fn(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigAndSelfHosted(SentryBridgeTestCase):
|
||||||
|
def test_self_hosted_base_url_is_used_and_flagged(self):
|
||||||
|
config = _config()
|
||||||
|
self.assertTrue(config.as_dict()["self_hosted"])
|
||||||
|
http = FakeHttp()
|
||||||
|
bridge.list_issues(config, token=TOKEN, http_fn=http)
|
||||||
|
self.assertTrue(http.calls[0].startswith(f"{BASE_URL}/api/0/projects/"))
|
||||||
|
self.assertIn(f"/projects/{SENTRY_ORG}/{SENTRY_PROJECT}/issues/", http.calls[0])
|
||||||
|
self.assertIn("statsPeriod=24h", http.calls[0])
|
||||||
|
|
||||||
|
def test_config_never_exposes_token(self):
|
||||||
|
config = bridge.load_bridge_config(
|
||||||
|
{
|
||||||
|
bridge.ENV_BASE_URL: BASE_URL,
|
||||||
|
bridge.ENV_ORG: SENTRY_ORG,
|
||||||
|
bridge.ENV_PROJECT: SENTRY_PROJECT,
|
||||||
|
bridge.ENV_AUTH_TOKEN: "super-secret-value",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
serialized = json.dumps(config.as_dict())
|
||||||
|
self.assertNotIn("super-secret-value", serialized)
|
||||||
|
self.assertNotIn("token", serialized.lower())
|
||||||
|
|
||||||
|
def test_invalid_lookback_falls_back_to_default(self):
|
||||||
|
config = bridge.load_bridge_config({bridge.ENV_LOOKBACK: "not-a-window"})
|
||||||
|
self.assertEqual(config.lookback, bridge.DEFAULT_LOOKBACK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingTokenAndUnavailable(SentryBridgeTestCase):
|
||||||
|
def test_missing_token_fails_closed_without_http_call(self):
|
||||||
|
http = FakeHttp()
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.list_issues(_config(), token="", http_fn=http)
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_MISSING_TOKEN)
|
||||||
|
self.assertEqual(http.calls, [], "no HTTP call may be made without a token")
|
||||||
|
|
||||||
|
def test_unconfigured_project_fails_closed(self):
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.list_issues(_config(project=""), token=TOKEN, http_fn=FakeHttp())
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_NOT_CONFIGURED)
|
||||||
|
|
||||||
|
def test_unauthorized_status_maps_to_missing_token(self):
|
||||||
|
http = FakeHttp(routes=[(401, {"detail": "Invalid token"}, {})])
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_MISSING_TOKEN)
|
||||||
|
|
||||||
|
def test_server_error_maps_to_unavailable(self):
|
||||||
|
http = FakeHttp(routes=[(502, {"detail": "bad gateway"}, {})])
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_UNAVAILABLE)
|
||||||
|
|
||||||
|
def test_urlerror_from_default_handler_maps_to_unavailable(self):
|
||||||
|
"""The real urllib handler must translate URLError, not leak it."""
|
||||||
|
|
||||||
|
def boom(request, timeout=None):
|
||||||
|
raise urllib.error.URLError("connection refused")
|
||||||
|
|
||||||
|
with unittest.mock.patch.object(urllib.request, "urlopen", boom):
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge._default_http_fn("https://sentry.prgs.cc/api/0/x/", {}, 1.0)
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_UNAVAILABLE)
|
||||||
|
|
||||||
|
def test_watchdog_reports_unavailable_without_mutating(self):
|
||||||
|
def failing(url, headers, timeout):
|
||||||
|
raise bridge.SentryApiError(
|
||||||
|
"Sentry unreachable", kind=bridge.ERROR_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._watchdog(failing)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error_kind"], bridge.ERROR_UNAVAILABLE)
|
||||||
|
self.assertEqual(self.created, [], "no Gitea issue on Sentry outage")
|
||||||
|
|
||||||
|
def test_invalid_json_fails_closed(self):
|
||||||
|
http = FakeHttp(routes=[(200, b"<html>not json</html>", {})])
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.list_issues(_config(), token=TOKEN, http_fn=http)
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_INVALID_RESPONSE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPagination(SentryBridgeTestCase):
|
||||||
|
def test_link_header_cursor_is_followed(self):
|
||||||
|
page1 = (
|
||||||
|
200,
|
||||||
|
[_raw_issue("4001")],
|
||||||
|
{
|
||||||
|
"link": (
|
||||||
|
f'<{BASE_URL}/api/0/x/?cursor=c1>; rel="previous"; results="false", '
|
||||||
|
f'<{BASE_URL}/api/0/x/?cursor=c2>; rel="next"; results="true"; cursor="c2"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
page2 = (
|
||||||
|
200,
|
||||||
|
[_raw_issue("4002")],
|
||||||
|
{
|
||||||
|
"link": (
|
||||||
|
f'<{BASE_URL}/api/0/x/?cursor=c3>; rel="next"; '
|
||||||
|
'results="false"; cursor="c3"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
http = FakeHttp(routes=[page1, page2])
|
||||||
|
result = bridge.list_issues(_config(), token=TOKEN, http_fn=http)
|
||||||
|
self.assertEqual(result["pages_fetched"], 2)
|
||||||
|
self.assertEqual([i["id"] for i in result["issues"]], ["4001", "4002"])
|
||||||
|
self.assertTrue(result["inventory_complete"])
|
||||||
|
self.assertIn("cursor=c2", http.calls[1])
|
||||||
|
|
||||||
|
def test_max_pages_caps_traversal_and_reports_incomplete(self):
|
||||||
|
page = (
|
||||||
|
200,
|
||||||
|
[_raw_issue("4001")],
|
||||||
|
{"link": f'<{BASE_URL}/x>; rel="next"; results="true"; cursor="cN"'},
|
||||||
|
)
|
||||||
|
http = FakeHttp(routes=[page])
|
||||||
|
result = bridge.list_issues(_config(), token=TOKEN, http_fn=http, max_pages=3)
|
||||||
|
self.assertEqual(result["pages_fetched"], 3)
|
||||||
|
self.assertFalse(result["inventory_complete"])
|
||||||
|
|
||||||
|
def test_parse_next_cursor_ignores_exhausted_results(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
bridge.parse_next_cursor('<u>; rel="next"; results="false"; cursor="c"')
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
bridge.parse_next_cursor('<u>; rel="next"; results="true"; cursor="c9"'),
|
||||||
|
"c9",
|
||||||
|
)
|
||||||
|
self.assertIsNone(bridge.parse_next_cursor(None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedaction(SentryBridgeTestCase):
|
||||||
|
def test_secrets_and_paths_are_scrubbed(self):
|
||||||
|
raw = _raw_issue(
|
||||||
|
title="RuntimeError: token=abc123supersecret failed",
|
||||||
|
culprit="/Users/jasonwalker/Development/Gitea-Tools/lease_lifecycle.py",
|
||||||
|
metadata={"type": "RuntimeError", "value": "password=hunter2"},
|
||||||
|
)
|
||||||
|
sanitized = bridge.sanitize_issue(raw)
|
||||||
|
blob = json.dumps(sanitized)
|
||||||
|
self.assertNotIn("abc123supersecret", blob)
|
||||||
|
self.assertNotIn("hunter2", blob)
|
||||||
|
self.assertNotIn("/Users/jasonwalker", blob)
|
||||||
|
self.assertIn("[REDACTED]", sanitized["title"])
|
||||||
|
|
||||||
|
def test_permalink_with_embedded_credentials_is_dropped(self):
|
||||||
|
raw = _raw_issue(permalink="https://user:[email protected]/issues/4001/")
|
||||||
|
self.assertIsNone(bridge.sanitize_issue(raw)["permalink"])
|
||||||
|
|
||||||
|
def test_sensitive_event_tags_are_removed(self):
|
||||||
|
event = _raw_event(
|
||||||
|
tags=[
|
||||||
|
{"key": "authorization", "value": "Bearer abc123secrettoken"},
|
||||||
|
{"key": "role", "value": "author"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sanitized = bridge.sanitize_event(event)
|
||||||
|
blob = json.dumps(sanitized)
|
||||||
|
self.assertNotIn("abc123secrettoken", blob)
|
||||||
|
self.assertEqual(sanitized["tags"].get("role"), "author")
|
||||||
|
|
||||||
|
def test_token_never_appears_in_watchdog_output(self):
|
||||||
|
result = self._watchdog(FakeHttp())
|
||||||
|
self.assertNotIn(TOKEN, json.dumps(result))
|
||||||
|
|
||||||
|
def test_issue_without_id_fails_closed(self):
|
||||||
|
with self.assertRaises(bridge.SentryApiError) as ctx:
|
||||||
|
bridge.sanitize_issue({"title": "no id"})
|
||||||
|
self.assertEqual(ctx.exception.kind, bridge.ERROR_INVALID_RESPONSE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateUpdateDedupe(SentryBridgeTestCase):
|
||||||
|
def test_dry_run_creates_nothing(self):
|
||||||
|
result = self._watchdog(FakeHttp(), apply=False)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["reconciled"], 1)
|
||||||
|
self.assertEqual(result["results"][0]["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(self.created, [], "dry-run must not create Gitea issues")
|
||||||
|
|
||||||
|
def test_apply_creates_one_durable_gitea_issue(self):
|
||||||
|
result = self._watchdog(FakeHttp())
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["results"][0]["outcome"], OUTCOME_CREATED)
|
||||||
|
self.assertEqual(len(self.created), 1)
|
||||||
|
created = self.created[0]
|
||||||
|
self.assertEqual(created["org"], GITEA_ORG)
|
||||||
|
self.assertEqual(created["repo"], GITEA_REPO)
|
||||||
|
self.assertIn("sentry", created["labels"])
|
||||||
|
# AC5: body carries the Sentry id and the first-seen window.
|
||||||
|
self.assertIn("4001", created["body"])
|
||||||
|
self.assertIn("2026-07-18T04:11:02", created["body"])
|
||||||
|
|
||||||
|
def test_repeat_scan_dedupes_to_a_single_issue(self):
|
||||||
|
first = self._watchdog(FakeHttp())
|
||||||
|
second = self._watchdog(FakeHttp())
|
||||||
|
self.assertEqual(first["results"][0]["outcome"], OUTCOME_CREATED)
|
||||||
|
self.assertEqual(second["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||||
|
self.assertEqual(len(self.created), 1, "recurrence must not create a duplicate")
|
||||||
|
|
||||||
|
def test_recurrence_updates_link_event_count(self):
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||||
|
result = self._watchdog(recurring)
|
||||||
|
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||||
|
self.assertEqual(self._link()["event_count"], 42)
|
||||||
|
|
||||||
|
def test_recurrence_posts_a_comment_on_the_second_scan(self):
|
||||||
|
"""AC4: continued Sentry events comment on the linked Gitea issue."""
|
||||||
|
first = self._watchdog(FakeHttp())
|
||||||
|
self.assertEqual(first["results"][0]["outcome"], OUTCOME_CREATED)
|
||||||
|
self.assertEqual(self.comments, [], "creation must not post a recurrence comment")
|
||||||
|
|
||||||
|
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||||
|
second = self._watchdog(recurring)
|
||||||
|
|
||||||
|
self.assertEqual(second["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||||
|
self.assertEqual(len(self.comments), 1, "recurrence must post exactly one comment")
|
||||||
|
comment = self.comments[0]
|
||||||
|
linked_number = int(self._link()["gitea_issue_number"])
|
||||||
|
self.assertEqual(comment["issue_number"], linked_number)
|
||||||
|
self.assertEqual(comment["org"], GITEA_ORG)
|
||||||
|
self.assertEqual(comment["repo"], GITEA_REPO)
|
||||||
|
# AC5 fields carried on the recurrence record.
|
||||||
|
self.assertIn("4001", comment["body"])
|
||||||
|
self.assertIn("42", comment["body"])
|
||||||
|
self.assertIn("recurrence_basis", comment["body"])
|
||||||
|
reported = second["results"][0]["recurrence_comment"]
|
||||||
|
self.assertTrue(reported["posted"])
|
||||||
|
self.assertEqual(reported["comment_id"], comment["comment_id"])
|
||||||
|
self.assertEqual(len(self.created), 1, "recurrence must not create a duplicate issue")
|
||||||
|
|
||||||
|
def test_dry_run_scan_posts_no_recurrence_comment(self):
|
||||||
|
"""AC4 + AC8: dry run never comments, even on a linked recurrence."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||||
|
result = self._watchdog(recurring, apply=False)
|
||||||
|
|
||||||
|
self.assertEqual(result["results"][0]["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(self.comments, [], "dry-run must not post recurrence comments")
|
||||||
|
|
||||||
|
def test_repeat_scan_without_new_events_posts_no_comment(self):
|
||||||
|
"""A scan that observes no new events must stay silent."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
result = self._watchdog(FakeHttp())
|
||||||
|
|
||||||
|
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||||
|
self.assertEqual(self.comments, [], "unchanged event state must not comment")
|
||||||
|
self.assertFalse(result["results"][0]["recurrence_comment"]["posted"])
|
||||||
|
|
||||||
|
def test_recurrence_comment_failure_keeps_the_link_durable(self):
|
||||||
|
"""A failed comment must not roll back or block the incident_links row."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
|
||||||
|
def failing_comment(issue_number, body, g_org, g_repo):
|
||||||
|
raise RuntimeError("gitea comment route unavailable")
|
||||||
|
|
||||||
|
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||||
|
result = bridge.watchdog(
|
||||||
|
self.db,
|
||||||
|
_config(),
|
||||||
|
token=TOKEN,
|
||||||
|
apply=True,
|
||||||
|
mappings=[_mapping()],
|
||||||
|
http_fn=recurring,
|
||||||
|
create_issue_fn=self._create_issue_fn(),
|
||||||
|
comment_issue_fn=failing_comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = result["results"][0]
|
||||||
|
self.assertEqual(entry["outcome"], OUTCOME_UPDATED)
|
||||||
|
self.assertFalse(entry["recurrence_comment"]["posted"])
|
||||||
|
self.assertEqual(self._link()["event_count"], 42, "link must still be updated")
|
||||||
|
|
||||||
|
def test_recurrence_comment_is_redacted(self):
|
||||||
|
"""AC2: recurrence comments pass through the same redaction path."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
recurring = FakeHttp(
|
||||||
|
routes=[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
[
|
||||||
|
_raw_issue(
|
||||||
|
"4001",
|
||||||
|
count="42",
|
||||||
|
metadata={
|
||||||
|
"type": "RuntimeError",
|
||||||
|
"value": "token=abc123supersecret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._watchdog(recurring)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.comments), 1)
|
||||||
|
body = self.comments[0]["body"]
|
||||||
|
self.assertNotIn("abc123supersecret", body)
|
||||||
|
self.assertNotIn(TOKEN, body)
|
||||||
|
|
||||||
|
def test_link_survives_a_new_db_handle(self):
|
||||||
|
"""AC6: bridge mapping survives process restarts."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
reopened = ControlPlaneDB(self.db_path)
|
||||||
|
link = reopened.get_incident_link_by_provider(
|
||||||
|
provider="sentry",
|
||||||
|
provider_issue_id="4001",
|
||||||
|
provider_base_url=BASE_URL,
|
||||||
|
provider_org=SENTRY_ORG,
|
||||||
|
provider_project=SENTRY_PROJECT,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(int(link["gitea_issue_number"]), 901)
|
||||||
|
|
||||||
|
def test_resolved_issue_is_not_recreated_or_reopened(self):
|
||||||
|
"""AC7: a resolved Sentry issue never creates or reopens Gitea work."""
|
||||||
|
self._watchdog(FakeHttp())
|
||||||
|
linked_number = int(self._link()["gitea_issue_number"])
|
||||||
|
closed = FakeHttp(routes=[(200, [_raw_issue("4001", status="resolved")], {})])
|
||||||
|
result = self._watchdog(closed)
|
||||||
|
self.assertEqual(result["skipped"], 1)
|
||||||
|
self.assertEqual(result["results"][0]["action"], bridge.ACTION_SKIPPED_STATUS)
|
||||||
|
self.assertEqual(len(self.created), 1)
|
||||||
|
self.assertEqual(linked_number, 901)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolicyGates(SentryBridgeTestCase):
|
||||||
|
def test_below_threshold_issue_is_skipped(self):
|
||||||
|
http = FakeHttp(routes=[(200, [_raw_issue("4001", count="1")], {})])
|
||||||
|
result = self._watchdog(http)
|
||||||
|
self.assertEqual(result["skipped"], 1)
|
||||||
|
self.assertEqual(result["results"][0]["action"], bridge.ACTION_SKIPPED_THRESHOLD)
|
||||||
|
self.assertEqual(self.created, [])
|
||||||
|
|
||||||
|
def test_apply_refused_when_bridge_disabled(self):
|
||||||
|
result = self._watchdog(FakeHttp(), config=_config(bridge_enabled=False))
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error_kind"], bridge.ERROR_BRIDGE_DISABLED)
|
||||||
|
self.assertEqual(self.created, [])
|
||||||
|
|
||||||
|
def test_dry_run_allowed_while_bridge_disabled(self):
|
||||||
|
result = self._watchdog(
|
||||||
|
FakeHttp(), apply=False, config=_config(bridge_enabled=False)
|
||||||
|
)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
def test_raw_incident_is_never_assignable_work(self):
|
||||||
|
result = self._watchdog(FakeHttp())
|
||||||
|
self.assertFalse(result["raw_incident_assignable"])
|
||||||
|
self.assertEqual(result["durable_work_system"], "gitea_issues")
|
||||||
|
|
||||||
|
def test_reconcile_failure_is_isolated_and_redacted(self):
|
||||||
|
def exploding(db, **kwargs):
|
||||||
|
raise RuntimeError("token=abc123 boom")
|
||||||
|
|
||||||
|
result = self._watchdog(FakeHttp(), reconcile_fn=exploding)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["failed"], 1)
|
||||||
|
self.assertNotIn("abc123", json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservationMapping(SentryBridgeTestCase):
|
||||||
|
def test_observation_carries_provider_identity_and_targets(self):
|
||||||
|
issue = bridge.sanitize_issue(_raw_issue())
|
||||||
|
event = bridge.sanitize_event(_raw_event())
|
||||||
|
obs = bridge.observation_from_issue(
|
||||||
|
issue,
|
||||||
|
_config(),
|
||||||
|
gitea_org=GITEA_ORG,
|
||||||
|
gitea_repo=GITEA_REPO,
|
||||||
|
latest_event=event,
|
||||||
|
)
|
||||||
|
self.assertEqual(obs["provider"], "sentry")
|
||||||
|
self.assertEqual(obs["provider_base_url"], BASE_URL)
|
||||||
|
self.assertEqual(obs["provider_issue_id"], "4001")
|
||||||
|
self.assertEqual(obs["event_count"], 7)
|
||||||
|
self.assertEqual(obs["environment"], "prod")
|
||||||
|
self.assertEqual(obs["gitea_repo"], GITEA_REPO)
|
||||||
|
self.assertTrue(_mapping().matches_observation(obs))
|
||||||
|
|
||||||
|
def test_events_fetch_returns_latest_first(self):
|
||||||
|
result = bridge.get_issue_events(
|
||||||
|
_config(), "4001", token=TOKEN, http_fn=FakeHttp()
|
||||||
|
)
|
||||||
|
self.assertEqual(result["count"], 1)
|
||||||
|
self.assertEqual(result["latest_event"]["environment"], "prod")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpToolWrappers(unittest.TestCase):
|
||||||
|
"""The registered MCP tools must fail closed, never raise, never leak."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Read-capable profile, fully configured Sentry target, but
|
||||||
|
# deliberately no SENTRY_AUTH_TOKEN — the token gap is the only fault.
|
||||||
|
self.env = shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
**{
|
||||||
|
bridge.ENV_BASE_URL: BASE_URL,
|
||||||
|
bridge.ENV_ORG: SENTRY_ORG,
|
||||||
|
bridge.ENV_PROJECT: SENTRY_PROJECT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.env.pop(bridge.ENV_AUTH_TOKEN, None)
|
||||||
|
|
||||||
|
def _server(self):
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
return gitea_mcp_server
|
||||||
|
|
||||||
|
def test_all_five_tools_are_registered(self):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
tools = asyncio.run(self._server().mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools if t.name.startswith("gitea_sentry_")}
|
||||||
|
self.assertEqual(
|
||||||
|
registered,
|
||||||
|
{
|
||||||
|
"gitea_sentry_list_issues",
|
||||||
|
"gitea_sentry_get_issue_events",
|
||||||
|
"gitea_sentry_reconcile_issue",
|
||||||
|
"gitea_sentry_link_gitea_issue",
|
||||||
|
"gitea_sentry_watchdog",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_issues_without_token_fails_closed(self):
|
||||||
|
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_list_issues()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
|
||||||
|
self.assertEqual(result["issues"], [])
|
||||||
|
|
||||||
|
def test_get_issue_events_without_token_fails_closed(self):
|
||||||
|
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_get_issue_events("4001")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
|
||||||
|
|
||||||
|
def test_reconcile_without_token_fails_closed_without_mutation(self):
|
||||||
|
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_reconcile_issue("4001", apply=True)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["raw_incident_assignable"])
|
||||||
|
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
|
||||||
|
|
||||||
|
def test_watchdog_without_token_fails_closed(self):
|
||||||
|
with unittest.mock.patch.dict(os.environ, self.env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_watchdog()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result.get("error_kind"), bridge.ERROR_MISSING_TOKEN)
|
||||||
|
|
||||||
|
def test_unconfigured_target_reports_not_configured_before_token(self):
|
||||||
|
env = {k: v for k, v in self.env.items() if not k.startswith("SENTRY_")}
|
||||||
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_list_issues()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result.get("error_kind"), bridge.ERROR_NOT_CONFIGURED)
|
||||||
|
|
||||||
|
def test_tool_output_never_contains_a_token_value(self):
|
||||||
|
env = dict(self.env)
|
||||||
|
env[bridge.ENV_AUTH_TOKEN] = "leaky-token-value"
|
||||||
|
with unittest.mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
result = self._server().gitea_sentry_list_issues()
|
||||||
|
self.assertNotIn("leaky-token-value", json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""Tests for the stable-control runtime mode gates (#615).
|
||||||
|
|
||||||
|
Covers acceptance criteria 6-11: runtime mode + SHA reporting, the fail-closed
|
||||||
|
mutation gates (dev-test targeting production, unknown runtime, dirty stable
|
||||||
|
checkout, dev-worktree launch, unsafe alignment), per-namespace post-flap
|
||||||
|
re-proving, promotion-record completeness, and the policy statements that keep
|
||||||
|
normal sessions from restarting the stable MCP runtime.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import stable_control_runtime as scr # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
SHA_A = "a" * 40
|
||||||
|
SHA_B = "b" * 40
|
||||||
|
|
||||||
|
STABLE_ROOT = "/Users/dev/Development/Gitea-Tools"
|
||||||
|
DEV_WORKTREE_ROOT = "/Users/dev/Development/Gitea-Tools/branches/issue-615-work"
|
||||||
|
|
||||||
|
|
||||||
|
def stable_report(**overrides):
|
||||||
|
"""A healthy stable-control runtime report, overridable per test."""
|
||||||
|
base = dict(
|
||||||
|
process_root=STABLE_ROOT,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head=SHA_A,
|
||||||
|
active_task_workspace=STABLE_ROOT,
|
||||||
|
canonical_repository_root=STABLE_ROOT,
|
||||||
|
repository_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
profile="prgs-author",
|
||||||
|
authenticated_identity="jcwalker3",
|
||||||
|
dirty_files=[],
|
||||||
|
workspace_roots_aligned=True,
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return scr.build_runtime_report(**base)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyRuntimeMode(unittest.TestCase):
|
||||||
|
def test_stable_branch_checkout_is_stable_control(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root=STABLE_ROOT, checkout_branch="master")
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
||||||
|
self.assertFalse(res["dev_worktree_launched"])
|
||||||
|
|
||||||
|
def test_main_and_dev_are_also_stable(self):
|
||||||
|
for branch in ("main", "dev"):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root=STABLE_ROOT, checkout_branch=branch)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE, branch)
|
||||||
|
|
||||||
|
def test_branches_worktree_launch_is_dev_test(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root=DEV_WORKTREE_ROOT,
|
||||||
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
||||||
|
)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
|
||||||
|
self.assertTrue(res["dev_worktree_launched"])
|
||||||
|
|
||||||
|
def test_feature_branch_outside_branches_is_still_dev_test(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root="/Users/dev/Development/scratch-clone",
|
||||||
|
checkout_branch="feat/experiment",
|
||||||
|
)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
|
||||||
|
self.assertFalse(res["dev_worktree_launched"])
|
||||||
|
|
||||||
|
def test_unresolvable_root_is_unknown(self):
|
||||||
|
res = scr.classify_runtime_mode(process_root=None, checkout_branch=None)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
||||||
|
|
||||||
|
def test_non_git_root_is_unknown(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root="/opt/gitea-tools-release",
|
||||||
|
checkout_branch=None,
|
||||||
|
is_git_checkout=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
||||||
|
|
||||||
|
def test_detached_head_is_unknown(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root=STABLE_ROOT, checkout_branch=None)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
||||||
|
|
||||||
|
def test_operator_declaration_wins_over_inference(self):
|
||||||
|
res = scr.classify_runtime_mode(
|
||||||
|
process_root="/opt/gitea-tools-release",
|
||||||
|
checkout_branch=None,
|
||||||
|
is_git_checkout=False,
|
||||||
|
declared_mode=scr.RUNTIME_MODE_STABLE,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
||||||
|
self.assertTrue(res["declared"])
|
||||||
|
|
||||||
|
def test_invalid_declaration_is_ignored(self):
|
||||||
|
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "production-ish"}):
|
||||||
|
self.assertIsNone(scr.declared_runtime_mode())
|
||||||
|
|
||||||
|
def test_valid_declaration_is_read_from_env(self):
|
||||||
|
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "dev-test"}):
|
||||||
|
self.assertEqual(scr.declared_runtime_mode(), scr.RUNTIME_MODE_DEV_TEST)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeReport(unittest.TestCase):
|
||||||
|
"""Acceptance criterion 6: runtime mode and SHA reporting."""
|
||||||
|
|
||||||
|
def test_report_carries_every_required_field(self):
|
||||||
|
report = stable_report()
|
||||||
|
for field in (
|
||||||
|
"runtime_mode",
|
||||||
|
"runtime_git_sha",
|
||||||
|
"runtime_branch",
|
||||||
|
"runtime_checkout_path",
|
||||||
|
"mcp_process_root",
|
||||||
|
"active_task_workspace",
|
||||||
|
"repository_slug",
|
||||||
|
"profile",
|
||||||
|
"authenticated_identity",
|
||||||
|
"dirty_files",
|
||||||
|
"workspace_roots_aligned",
|
||||||
|
"real_mutations_allowed",
|
||||||
|
):
|
||||||
|
self.assertIn(field, report, field)
|
||||||
|
|
||||||
|
def test_report_records_the_runtime_sha(self):
|
||||||
|
self.assertEqual(stable_report()["runtime_git_sha"], SHA_A)
|
||||||
|
|
||||||
|
def test_format_summarises_mode_sha_and_branch(self):
|
||||||
|
summary = scr.format_runtime_mode(stable_report())
|
||||||
|
self.assertIn(scr.RUNTIME_MODE_STABLE, summary)
|
||||||
|
self.assertIn(SHA_A[:12], summary)
|
||||||
|
self.assertIn("master", summary)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationGate(unittest.TestCase):
|
||||||
|
"""Acceptance criterion 7: fail-closed mutation gates."""
|
||||||
|
|
||||||
|
def test_stable_healthy_runtime_allows_real_mutations(self):
|
||||||
|
report = stable_report()
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertFalse(gate["block"])
|
||||||
|
self.assertEqual(gate["reasons"], [])
|
||||||
|
self.assertTrue(report["real_mutations_allowed"])
|
||||||
|
|
||||||
|
def test_dev_test_runtime_blocks_real_production_mutations(self):
|
||||||
|
report = stable_report(
|
||||||
|
process_root=DEV_WORKTREE_ROOT,
|
||||||
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
||||||
|
active_task_workspace=DEV_WORKTREE_ROOT,
|
||||||
|
)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertIn(scr.BLOCKER_DEV_TEST_PRODUCTION, gate["blocker_kinds"])
|
||||||
|
self.assertFalse(report["real_mutations_allowed"])
|
||||||
|
|
||||||
|
def test_dev_test_runtime_may_mutate_a_non_production_target(self):
|
||||||
|
report = stable_report(
|
||||||
|
process_root=DEV_WORKTREE_ROOT,
|
||||||
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
||||||
|
)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(
|
||||||
|
report, target_is_production=False)
|
||||||
|
self.assertFalse(gate["block"])
|
||||||
|
|
||||||
|
def test_unknown_runtime_blocks_mutations(self):
|
||||||
|
report = stable_report(checkout_branch=None)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertIn(scr.BLOCKER_UNKNOWN_RUNTIME, gate["blocker_kinds"])
|
||||||
|
|
||||||
|
def test_unknown_runtime_blocks_even_a_non_production_target(self):
|
||||||
|
report = stable_report(checkout_branch=None)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(
|
||||||
|
report, target_is_production=False)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
|
||||||
|
def test_dirty_stable_runtime_blocks_mutations(self):
|
||||||
|
report = stable_report(dirty_files=["gitea_mcp_server.py"])
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertIn(scr.BLOCKER_DIRTY_STABLE_RUNTIME, gate["blocker_kinds"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("dirty" in reason for reason in gate["reasons"]))
|
||||||
|
|
||||||
|
def test_dev_worktree_launch_is_reported_as_its_own_blocker(self):
|
||||||
|
report = stable_report(
|
||||||
|
process_root=DEV_WORKTREE_ROOT,
|
||||||
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
||||||
|
)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertIn(scr.BLOCKER_DEV_WORKTREE_LAUNCH, gate["blocker_kinds"])
|
||||||
|
|
||||||
|
def test_unsafe_workspace_alignment_blocks_mutations(self):
|
||||||
|
report = stable_report(workspace_roots_aligned=False)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertIn(scr.BLOCKER_UNSAFE_ALIGNMENT, gate["blocker_kinds"])
|
||||||
|
|
||||||
|
def test_unknown_alignment_does_not_block(self):
|
||||||
|
report = stable_report(workspace_roots_aligned=None)
|
||||||
|
self.assertFalse(scr.assess_runtime_mutation_gate(report)["block"])
|
||||||
|
|
||||||
|
def test_env_escape_hatch_disables_the_gate(self):
|
||||||
|
report = stable_report(checkout_branch=None)
|
||||||
|
with patch.dict(os.environ, {scr.ENV_DISABLE: "1"}):
|
||||||
|
gate = scr.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertFalse(gate["block"])
|
||||||
|
self.assertTrue(gate["gate_disabled"])
|
||||||
|
|
||||||
|
def test_block_reasons_helper_matches_the_gate(self):
|
||||||
|
report = stable_report(checkout_branch=None)
|
||||||
|
self.assertEqual(
|
||||||
|
scr.runtime_block_reasons(report),
|
||||||
|
scr.assess_runtime_mutation_gate(report)["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_block_payload_names_the_operator_recovery_path(self):
|
||||||
|
report = stable_report(checkout_branch=None)
|
||||||
|
payload = scr.runtime_report_payload(report)
|
||||||
|
self.assertEqual(payload["kind"], "runtime_mode_block")
|
||||||
|
self.assertEqual(payload["blocker_kind"], scr.BLOCKER_UNKNOWN_RUNTIME)
|
||||||
|
self.assertTrue(
|
||||||
|
any("promotion-runbook" in line for line in payload["recovery"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostFlapReproving(unittest.TestCase):
|
||||||
|
"""Acceptance criterion 8: per-namespace post-flap re-proving."""
|
||||||
|
|
||||||
|
def test_no_flap_means_no_reproof_required(self):
|
||||||
|
state = scr.new_reproof_state()
|
||||||
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
||||||
|
self.assertFalse(res["reproof_required"])
|
||||||
|
self.assertTrue(res["proven"])
|
||||||
|
|
||||||
|
def test_transport_recovery_requires_namespace_specific_reproving(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
||||||
|
self.assertTrue(res["reproof_required"])
|
||||||
|
self.assertFalse(res["proven"])
|
||||||
|
self.assertEqual(
|
||||||
|
res["missing_steps"], list(scr.REQUIRED_NAMESPACE_PROOF_STEPS))
|
||||||
|
|
||||||
|
def test_author_proof_does_not_imply_other_namespaces(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
state = scr.record_namespace_proof(
|
||||||
|
state,
|
||||||
|
"author",
|
||||||
|
at="2026-07-20T14:05:00Z",
|
||||||
|
whoami=True,
|
||||||
|
runtime_context=True,
|
||||||
|
capability_resolved=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(scr.assess_namespace_reproof(state, "author")["proven"])
|
||||||
|
for other in ("reviewer", "merger", "reconciler"):
|
||||||
|
assessment = scr.assess_namespace_reproof(state, other)
|
||||||
|
self.assertFalse(assessment["proven"], other)
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not transfer" in reason
|
||||||
|
for reason in assessment["reasons"]),
|
||||||
|
other,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
scr.unproven_namespaces(state),
|
||||||
|
["reviewer", "merger", "reconciler"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proof_recorded_before_the_flap_does_not_count(self):
|
||||||
|
state = scr.record_namespace_proof(
|
||||||
|
scr.new_reproof_state(),
|
||||||
|
"merger",
|
||||||
|
at="2026-07-20T13:00:00Z",
|
||||||
|
whoami=True,
|
||||||
|
runtime_context=True,
|
||||||
|
capability_resolved=True,
|
||||||
|
)
|
||||||
|
state = scr.record_transport_flap(state, at="2026-07-20T14:00:00Z")
|
||||||
|
res = scr.assess_namespace_reproof(state, "merger")
|
||||||
|
self.assertFalse(res["proven"])
|
||||||
|
self.assertTrue(any("predates" in reason for reason in res["reasons"]))
|
||||||
|
|
||||||
|
def test_incomplete_proof_lists_the_missing_steps(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
state = scr.record_namespace_proof(
|
||||||
|
state, "reviewer", at="2026-07-20T14:05:00Z", whoami=True)
|
||||||
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
||||||
|
self.assertFalse(res["proven"])
|
||||||
|
self.assertEqual(
|
||||||
|
res["missing_steps"], ["runtime_context", "capability_resolved"])
|
||||||
|
|
||||||
|
def test_stale_runtime_report_keeps_the_namespace_unproven(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
state = scr.record_namespace_proof(
|
||||||
|
state,
|
||||||
|
"reviewer",
|
||||||
|
at="2026-07-20T14:05:00Z",
|
||||||
|
whoami=True,
|
||||||
|
runtime_context=True,
|
||||||
|
capability_resolved=True,
|
||||||
|
stale_runtime_reported=True,
|
||||||
|
)
|
||||||
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
||||||
|
self.assertFalse(res["proven"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("stale-runtime" in reason for reason in res["reasons"]))
|
||||||
|
|
||||||
|
def test_unproven_namespace_blocks_the_mutation_gate(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
gate = scr.assess_runtime_mutation_gate(
|
||||||
|
stable_report(), namespace="reviewer", namespace_reproof=state)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertIn(scr.BLOCKER_NAMESPACE_NOT_REPROVEN, gate["blocker_kinds"])
|
||||||
|
|
||||||
|
def test_reproven_namespace_clears_the_mutation_gate(self):
|
||||||
|
state = scr.record_transport_flap(
|
||||||
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
||||||
|
state = scr.record_namespace_proof(
|
||||||
|
state,
|
||||||
|
"reviewer",
|
||||||
|
at="2026-07-20T14:05:00Z",
|
||||||
|
whoami=True,
|
||||||
|
runtime_context=True,
|
||||||
|
capability_resolved=True,
|
||||||
|
)
|
||||||
|
gate = scr.assess_runtime_mutation_gate(
|
||||||
|
stable_report(), namespace="reviewer", namespace_reproof=state)
|
||||||
|
self.assertFalse(gate["block"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromotionRecord(unittest.TestCase):
|
||||||
|
"""Acceptance criteria 4 / 10: promotion records previous and promoted SHAs."""
|
||||||
|
|
||||||
|
def complete_record(self, **overrides):
|
||||||
|
record = {
|
||||||
|
"previous_runtime_sha": SHA_A,
|
||||||
|
"promoted_runtime_sha": SHA_B,
|
||||||
|
"source_branch": "feat/issue-615-runtime-mode-enforcement",
|
||||||
|
"source_pr": "770",
|
||||||
|
"restart_method": "operator reload of the stable control runtime",
|
||||||
|
"health_check_proof": "gitea_assess_mcp_namespace_health: healthy",
|
||||||
|
"identity_proof": "gitea_whoami: sysadmin / prgs-reviewer",
|
||||||
|
"profile_proof": "runtime context: prgs-reviewer",
|
||||||
|
"workspace_proof": "process root == canonical root, clean",
|
||||||
|
"mutation_capability_proof": "resolve review_pr: allowed",
|
||||||
|
"rollback_instructions": "re-promote " + SHA_A,
|
||||||
|
}
|
||||||
|
record.update(overrides)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def test_complete_record_is_valid(self):
|
||||||
|
res = scr.assess_promotion_record(self.complete_record())
|
||||||
|
self.assertTrue(res["valid"])
|
||||||
|
self.assertEqual(res["missing_fields"], [])
|
||||||
|
|
||||||
|
def test_promotion_records_previous_and_promoted_shas(self):
|
||||||
|
res = scr.assess_promotion_record(
|
||||||
|
self.complete_record(previous_runtime_sha="", promoted_runtime_sha=""))
|
||||||
|
self.assertFalse(res["valid"])
|
||||||
|
self.assertIn("previous_runtime_sha", res["missing_fields"])
|
||||||
|
self.assertIn("promoted_runtime_sha", res["missing_fields"])
|
||||||
|
|
||||||
|
def test_identical_shas_are_not_a_promotion(self):
|
||||||
|
res = scr.assess_promotion_record(
|
||||||
|
self.complete_record(promoted_runtime_sha=SHA_A))
|
||||||
|
self.assertFalse(res["valid"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("nothing was promoted" in reason for reason in res["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_rollback_instructions_fail_closed(self):
|
||||||
|
res = scr.assess_promotion_record(
|
||||||
|
self.complete_record(rollback_instructions=""))
|
||||||
|
self.assertFalse(res["valid"])
|
||||||
|
self.assertIn("rollback_instructions", res["missing_fields"])
|
||||||
|
|
||||||
|
def test_empty_record_is_invalid(self):
|
||||||
|
self.assertFalse(scr.assess_promotion_record(None)["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalSessionsCannotRestartStableRuntime(unittest.TestCase):
|
||||||
|
"""Acceptance criterion 3: normal sessions do not restart the stable MCP."""
|
||||||
|
|
||||||
|
def test_adr_forbids_kill_restart_and_relaunch(self):
|
||||||
|
adr = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "architecture"
|
||||||
|
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
).read_text()
|
||||||
|
for phrase in ("Kill the running MCP server process",
|
||||||
|
"Restart / relaunch the MCP server process",
|
||||||
|
"Relaunch MCP from a development worktree"):
|
||||||
|
self.assertIn(phrase, adr, phrase)
|
||||||
|
|
||||||
|
def test_promotion_runbook_exists_and_lists_every_record_field(self):
|
||||||
|
runbook = (
|
||||||
|
REPO_ROOT / "docs" / "stable-runtime-promotion-runbook.md"
|
||||||
|
).read_text()
|
||||||
|
for field in scr.PROMOTION_REQUIRED_FIELDS:
|
||||||
|
self.assertIn(field, runbook, field)
|
||||||
|
|
||||||
|
def test_no_mcp_tool_offers_a_runtime_restart(self):
|
||||||
|
server = (REPO_ROOT / "gitea_mcp_server.py").read_text()
|
||||||
|
for forbidden in ("def gitea_restart_", "def gitea_kill_"):
|
||||||
|
self.assertNotIn(forbidden, server, forbidden)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerWiring(unittest.TestCase):
|
||||||
|
"""The gate is wired into the server's mutation permission path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as srv # imported lazily: heavy module
|
||||||
|
|
||||||
|
self.srv = srv
|
||||||
|
|
||||||
|
def test_reads_are_never_blocked_by_runtime_mode(self):
|
||||||
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}):
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.read"), [])
|
||||||
|
|
||||||
|
def test_gate_is_skipped_under_pure_unit_test_isolation(self):
|
||||||
|
# The suite itself runs from a branches/ worktree (dev-test by design);
|
||||||
|
# without forced production guards the gate must not fire.
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_dev_worktree_runtime_blocks_mutations_when_guards_forced(self):
|
||||||
|
report = stable_report(
|
||||||
|
process_root=DEV_WORKTREE_ROOT,
|
||||||
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
||||||
|
patch.object(
|
||||||
|
self.srv, "_current_runtime_mode_report", return_value=report):
|
||||||
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertTrue(any("dev-test" in reason for reason in reasons))
|
||||||
|
|
||||||
|
def test_stable_runtime_allows_mutations_when_guards_forced(self):
|
||||||
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
||||||
|
patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_current_runtime_mode_report",
|
||||||
|
return_value=stable_report()):
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_unassessable_runtime_fails_closed(self):
|
||||||
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
||||||
|
patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_current_runtime_mode_report",
|
||||||
|
side_effect=RuntimeError("boom")):
|
||||||
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertTrue(any("fail closed" in reason for reason in reasons))
|
||||||
|
|
||||||
|
def test_live_report_describes_this_checkout(self):
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertIn(report["runtime_mode"], scr.VALID_RUNTIME_MODES)
|
||||||
|
self.assertEqual(report["mcp_process_root"], self.srv.PROJECT_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerWiringRealDerivation(unittest.TestCase):
|
||||||
|
"""Drive the *real* report derivation, not a pre-built fixture (#615 F3).
|
||||||
|
|
||||||
|
Every other server-wiring test patches ``_current_runtime_mode_report`` with
|
||||||
|
a fixture, so the derivation the daemon actually runs was never executed by
|
||||||
|
the suite. These tests patch only its *inputs* -- the import-time facts, the
|
||||||
|
dirty-file read, and the resolved namespace binding -- and let the real
|
||||||
|
function build the report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_WORKTREE = STABLE_ROOT + "/branches/issue-615-runtime-mode-enforcement"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as srv # imported lazily: heavy module
|
||||||
|
|
||||||
|
self.srv = srv
|
||||||
|
|
||||||
|
def _stable_facts(self):
|
||||||
|
"""Immutable facts of a promoted stable-control runtime."""
|
||||||
|
return {
|
||||||
|
"checkout_branch": "master",
|
||||||
|
"runtime_head": SHA_A,
|
||||||
|
"is_git_checkout": True,
|
||||||
|
"dirty_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _binding(self, *, roots_aligned=True, workspace=None):
|
||||||
|
"""A resolved namespace binding, as the server's resolver returns it."""
|
||||||
|
return {
|
||||||
|
"workspace_path": workspace or self.TASK_WORKTREE,
|
||||||
|
"canonical_repo_root": STABLE_ROOT,
|
||||||
|
"process_project_root": STABLE_ROOT,
|
||||||
|
"roots_aligned": roots_aligned,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _real_derivation(self, *, dirty=None, roots_aligned=True, workspace=None):
|
||||||
|
"""Context managers that patch only the inputs, never the derivation."""
|
||||||
|
return (
|
||||||
|
patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}),
|
||||||
|
patch.object(self.srv, "PROJECT_ROOT", STABLE_ROOT),
|
||||||
|
patch.object(self.srv, "_STARTUP_RUNTIME_FACTS", self._stable_facts()),
|
||||||
|
patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
return_value=self._binding(
|
||||||
|
roots_aligned=roots_aligned, workspace=workspace),
|
||||||
|
),
|
||||||
|
patch.object(scr, "observe_dirty_files", return_value=list(dirty or [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_stable_checkout_with_bound_task_worktree_permits_mutation(self):
|
||||||
|
# The sanctioned configuration: a clean control checkout on master plus a
|
||||||
|
# correctly bound branches/ worktree. Before the F1 fix this failed, because
|
||||||
|
# alignment was path equality between the task workspace and the process
|
||||||
|
# root, which a branches/ worktree can never satisfy.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation()
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertEqual(report["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
||||||
|
self.assertEqual(report["active_task_workspace"], self.TASK_WORKTREE)
|
||||||
|
self.assertTrue(report["workspace_roots_aligned"])
|
||||||
|
self.assertTrue(
|
||||||
|
report["real_mutations_allowed"], report["mutation_block_reasons"])
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_misaligned_process_and_canonical_roots_fail_closed(self):
|
||||||
|
# Alignment keeps its repository-level meaning: the namespace targeting a
|
||||||
|
# different repository than the process is installed in is the unsafe case.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(roots_aligned=False)
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertFalse(report["workspace_roots_aligned"])
|
||||||
|
self.assertFalse(report["real_mutations_allowed"])
|
||||||
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertTrue(any("alignment" in reason for reason in reasons))
|
||||||
|
|
||||||
|
def test_newly_dirty_task_state_is_detected_after_an_earlier_clean_read(self):
|
||||||
|
# A clean read must not license every later mutation: the acceptance
|
||||||
|
# criterion 7 dirty blocker has to keep applying for the process lifetime.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertTrue(
|
||||||
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(
|
||||||
|
dirty=["gitea_mcp_server.py"])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertEqual(report["dirty_files"], ["gitea_mcp_server.py"])
|
||||||
|
self.assertFalse(report["real_mutations_allowed"])
|
||||||
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
||||||
|
|
||||||
|
def test_read_only_refresh_cannot_freeze_a_permissive_mutation_result(self):
|
||||||
|
# gitea_get_runtime_context() calls with refresh=True. That read-only call
|
||||||
|
# must not seed a cache that a later mutation gate would then trust.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertTrue(
|
||||||
|
self.srv._current_runtime_mode_report(refresh=True)[
|
||||||
|
"real_mutations_allowed"]
|
||||||
|
)
|
||||||
|
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(
|
||||||
|
dirty=["stable_control_runtime.py"])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertFalse(
|
||||||
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
||||||
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
||||||
|
|
||||||
|
def test_unresolvable_binding_reports_unknown_alignment_never_alignment_proof(self):
|
||||||
|
# An unresolvable binding must report alignment as unknown (None), never
|
||||||
|
# as True. Only *definite* misalignment blocks: a session with no task
|
||||||
|
# binding resolved is the ordinary case, and failing it closed would
|
||||||
|
# reintroduce exactly the F1 breakage this change removes.
|
||||||
|
env, root, facts, _, dirty = self._real_derivation()
|
||||||
|
broken = patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
side_effect=RuntimeError("no binding"),
|
||||||
|
)
|
||||||
|
with env, root, facts, broken, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertIsNone(report["workspace_roots_aligned"])
|
||||||
|
self.assertNotIn(
|
||||||
|
scr.BLOCKER_UNSAFE_ALIGNMENT,
|
||||||
|
scr.assess_runtime_mutation_gate(report)["blocker_kinds"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,7 +19,12 @@ import unittest
|
|||||||
|
|
||||||
import gitea_config
|
import gitea_config
|
||||||
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||||
from task_capability_map import required_permission, required_role
|
from task_capability_map import (
|
||||||
|
ROLE_EXCLUSIVE_TASKS,
|
||||||
|
TASK_CAPABILITY_MAP,
|
||||||
|
required_permission,
|
||||||
|
required_role,
|
||||||
|
)
|
||||||
|
|
||||||
# Canonical role-profile permission shape. Mirrors the configured
|
# Canonical role-profile permission shape. Mirrors the configured
|
||||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||||
@@ -112,6 +117,42 @@ FORMAL_REVIEW_TASKS = (
|
|||||||
"pr-queue-cleanup",
|
"pr-queue-cleanup",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Complete resolver role-exclusive set on master when #723 was reconstructed.
|
||||||
|
# The shared constant must replace this exact inline authority without dropping
|
||||||
|
# later lease and PR-sync aliases added after the preserved source commits.
|
||||||
|
EXPECTED_ROLE_EXCLUSIVE_TASKS = 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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _profile_satisfies(role_name, task):
|
def _profile_satisfies(role_name, task):
|
||||||
"""True when the canonical *role_name* profile can perform *task*."""
|
"""True when the canonical *role_name* profile can perform *task*."""
|
||||||
@@ -201,5 +242,30 @@ class TestMergerBoundary(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleExclusiveSetIntegrity(unittest.TestCase):
|
||||||
|
"""#723: the shared set is complete, mapped, and role-satisfiable."""
|
||||||
|
|
||||||
|
def test_complete_current_role_exclusive_set(self):
|
||||||
|
self.assertEqual(ROLE_EXCLUSIVE_TASKS, EXPECTED_ROLE_EXCLUSIVE_TASKS)
|
||||||
|
|
||||||
|
def test_every_role_exclusive_task_exists_in_capability_map(self):
|
||||||
|
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(task, TASK_CAPABILITY_MAP)
|
||||||
|
|
||||||
|
def test_formal_review_tasks_are_role_exclusive(self):
|
||||||
|
self.assertTrue(set(FORMAL_REVIEW_TASKS) <= ROLE_EXCLUSIVE_TASKS)
|
||||||
|
|
||||||
|
def test_every_role_exclusive_task_has_a_satisfying_profile(self):
|
||||||
|
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
role = required_role(task)
|
||||||
|
self.assertIn(role, CANONICAL_ROLE_PROFILES)
|
||||||
|
self.assertTrue(
|
||||||
|
_profile_satisfies(role, task),
|
||||||
|
f"canonical {role!r} profile cannot satisfy {task!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""#780: ``status:pr-open`` must not survive a terminal PR transition.
|
||||||
|
|
||||||
|
The leak this file locks down: ``gitea_create_pr`` applied ``status:pr-open``
|
||||||
|
and no terminal path ever removed it, so a repository audit found 40 closed
|
||||||
|
issues still advertising an open PR that had long since merged or closed.
|
||||||
|
|
||||||
|
Coverage mirrors the issue's acceptance criteria: merge, close-without-merge,
|
||||||
|
supersession, already-landed reconciliation, controller closure, retry /
|
||||||
|
idempotency, unrelated-label preservation, the only-label (empty set) case,
|
||||||
|
and terminal validation of any residual label.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import terminal_pr_label_cleanup as tplc
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_AUTH = "token test-token"
|
||||||
|
PR_OPEN = tplc.PR_OPEN_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def _lb(name: str, lid: int) -> dict:
|
||||||
|
return {"id": lid, "name": name, "color": "000000"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pure rule: planning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestPlanPrOpenCleanup(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_removes_only_the_pr_open_label(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", PR_OPEN, "workflow-hardening"],
|
||||||
|
terminal_reason=tplc.MERGED,
|
||||||
|
)
|
||||||
|
self.assertTrue(plan["cleanup_required"])
|
||||||
|
self.assertEqual(plan["removed"], [PR_OPEN])
|
||||||
|
self.assertEqual(plan["labels_after"], ["type:bug", "workflow-hardening"])
|
||||||
|
|
||||||
|
def test_preserves_unrelated_labels_in_original_order(self):
|
||||||
|
labels = ["workflow-hardening", "type:bug", PR_OPEN, "role:author", "leases"]
|
||||||
|
plan = tplc.plan_pr_open_cleanup(labels, terminal_reason=tplc.MERGED)
|
||||||
|
self.assertEqual(
|
||||||
|
plan["labels_after"],
|
||||||
|
["workflow-hardening", "type:bug", "role:author", "leases"],
|
||||||
|
)
|
||||||
|
self.assertNotIn(PR_OPEN, plan["labels_after"])
|
||||||
|
|
||||||
|
def test_only_label_yields_empty_set(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
|
||||||
|
self.assertTrue(plan["cleanup_required"])
|
||||||
|
self.assertEqual(plan["labels_after"], [])
|
||||||
|
self.assertTrue(plan["empty_label_set"])
|
||||||
|
|
||||||
|
def test_absent_label_is_an_idempotent_noop(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", "status:done"], terminal_reason=tplc.RETRY_RECOVERY
|
||||||
|
)
|
||||||
|
self.assertFalse(plan["cleanup_required"])
|
||||||
|
self.assertTrue(plan["idempotent_noop"])
|
||||||
|
self.assertEqual(plan["labels_after"], ["type:bug", "status:done"])
|
||||||
|
|
||||||
|
def test_accepts_gitea_label_objects(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
{"labels": [{"name": PR_OPEN}, {"name": "type:bug"}]},
|
||||||
|
terminal_reason=tplc.SUPERSEDED,
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["labels_after"], ["type:bug"])
|
||||||
|
|
||||||
|
def test_every_terminal_reason_is_planable(self):
|
||||||
|
for reason in tplc.TERMINAL_REASONS:
|
||||||
|
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=reason)
|
||||||
|
self.assertEqual(plan["terminal_reason"], reason)
|
||||||
|
self.assertTrue(plan["terminal_reason_description"])
|
||||||
|
|
||||||
|
def test_unknown_terminal_reason_fails_closed(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason="whenever")
|
||||||
|
|
||||||
|
def test_reason_aliases_normalize(self):
|
||||||
|
self.assertEqual(tplc.canonical_terminal_reason("merge"), tplc.MERGED)
|
||||||
|
self.assertEqual(
|
||||||
|
tplc.canonical_terminal_reason("already-landed"), tplc.ALREADY_LANDED
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tplc.canonical_terminal_reason("controller-closure"),
|
||||||
|
tplc.CONTROLLER_CLOSURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pure rule: read-after-write verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestVerifyPrOpenCleanup(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_verified_when_observed_matches_plan(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
|
||||||
|
self.assertTrue(result["verified"])
|
||||||
|
self.assertFalse(result["residual"])
|
||||||
|
self.assertEqual(result["reasons"], [])
|
||||||
|
|
||||||
|
def test_residual_label_is_reported(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
result = tplc.verify_pr_open_cleanup(["type:bug", PR_OPEN], plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertTrue(result["residual"])
|
||||||
|
self.assertIn(PR_OPEN, result["reasons"][0])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_dropped_unrelated_label_is_reported(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", "leases", PR_OPEN], terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertEqual(result["unexpected_removals"], ["leases"])
|
||||||
|
|
||||||
|
def test_unexpected_added_label_is_reported(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup(
|
||||||
|
["type:bug", PR_OPEN], terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
result = tplc.verify_pr_open_cleanup(["type:bug", "surprise"], plan=plan)
|
||||||
|
self.assertFalse(result["verified"])
|
||||||
|
self.assertEqual(result["unexpected_additions"], ["surprise"])
|
||||||
|
|
||||||
|
def test_empty_observed_set_verifies_for_only_label_case(self):
|
||||||
|
plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED)
|
||||||
|
result = tplc.verify_pr_open_cleanup([], plan=plan)
|
||||||
|
self.assertTrue(result["verified"])
|
||||||
|
self.assertTrue(result["empty_label_set"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Terminal validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestDetectResidualPrOpen(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_clean_repository(self):
|
||||||
|
issues = [
|
||||||
|
{"number": 1, "state": "closed", "labels": [{"name": "type:bug"}]},
|
||||||
|
{"number": 2, "state": "open", "labels": []},
|
||||||
|
]
|
||||||
|
result = tplc.detect_residual_pr_open(issues)
|
||||||
|
self.assertTrue(result["clean"])
|
||||||
|
self.assertEqual(result["residual_count"], 0)
|
||||||
|
self.assertEqual(result["checked_count"], 2)
|
||||||
|
|
||||||
|
def test_reports_each_stale_issue(self):
|
||||||
|
issues = [
|
||||||
|
{"number": 626, "state": "closed", "labels": [{"name": PR_OPEN}]},
|
||||||
|
{"number": 772, "state": "closed", "labels": [{"name": PR_OPEN}]},
|
||||||
|
{"number": 9, "state": "open", "labels": [{"name": "type:bug"}]},
|
||||||
|
]
|
||||||
|
result = tplc.detect_residual_pr_open(issues)
|
||||||
|
self.assertFalse(result["clean"])
|
||||||
|
self.assertEqual(result["residual_count"], 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[entry["number"] for entry in result["residual_issues"]], [626, 772]
|
||||||
|
)
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_issue_with_a_live_open_pr_is_not_residual(self):
|
||||||
|
issues = [{"number": 42, "state": "open", "labels": [{"name": PR_OPEN}]}]
|
||||||
|
result = tplc.detect_residual_pr_open(issues, open_pr_issue_numbers=[42])
|
||||||
|
self.assertTrue(result["clean"])
|
||||||
|
self.assertEqual(result["exempt_open_pr_issues"], [42])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Executor: one authoritative rule, with read-after-write proof
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class _ExecutorHarness(unittest.TestCase):
|
||||||
|
"""Drives mcp_server.clear_pr_open_label against a fake Gitea."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.issue_labels: dict[int, list[str]] = {}
|
||||||
|
self.repo_labels = {
|
||||||
|
PR_OPEN: 4,
|
||||||
|
"type:bug": 1,
|
||||||
|
"workflow-hardening": 2,
|
||||||
|
"leases": 3,
|
||||||
|
"status:done": 5,
|
||||||
|
}
|
||||||
|
self.puts: list[tuple[int, list[int]]] = []
|
||||||
|
|
||||||
|
patch("mcp_server._resolve", return_value=("h", "o", "r")).start()
|
||||||
|
patch("mcp_server._auth", return_value=FAKE_AUTH).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.repo_api_url",
|
||||||
|
return_value="https://gitea.example/api/v1/repos/o/r",
|
||||||
|
).start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
patch("mcp_server.api_request", side_effect=self._api).start()
|
||||||
|
# api_get_all resolves api_request inside gitea_auth, so patching the
|
||||||
|
# mcp_server binding alone would let the label inventory hit the network.
|
||||||
|
patch("mcp_server.api_get_all", side_effect=self._api_get_all).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def _api_get_all(self, url, auth, **_kwargs):
|
||||||
|
if "/labels" in url:
|
||||||
|
return [_lb(name, lid) for name, lid in self.repo_labels.items()]
|
||||||
|
raise AssertionError(f"unexpected paginated GET: {url}")
|
||||||
|
|
||||||
|
def _api(self, method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/issues/" in url:
|
||||||
|
num = int(url.rsplit("/issues/", 1)[1].split("?")[0])
|
||||||
|
return {
|
||||||
|
"number": num,
|
||||||
|
"labels": [
|
||||||
|
{"name": n, "id": self.repo_labels[n]}
|
||||||
|
for n in self.issue_labels.get(num, [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if method == "PUT" and url.endswith("/labels"):
|
||||||
|
num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
|
||||||
|
ids = payload["labels"]
|
||||||
|
by_id = {lid: name for name, lid in self.repo_labels.items()}
|
||||||
|
names = [by_id[i] for i in ids]
|
||||||
|
self.puts.append((num, ids))
|
||||||
|
self.issue_labels[num] = names
|
||||||
|
return [_lb(n, self.repo_labels[n]) for n in names]
|
||||||
|
raise AssertionError(f"unexpected API call: {method} {url}")
|
||||||
|
|
||||||
|
def _clear(self, numbers, reason=tplc.MERGED):
|
||||||
|
return mcp_server.clear_pr_open_label(
|
||||||
|
numbers, "prgs", None, None, None, terminal_reason=reason
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearPrOpenLabel(_ExecutorHarness):
|
||||||
|
|
||||||
|
def test_removes_label_and_preserves_the_rest(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN, "workflow-hardening"]
|
||||||
|
summary = self._clear([780])
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
self.assertEqual(summary["removed"], [780])
|
||||||
|
self.assertEqual(
|
||||||
|
self.issue_labels[780], ["type:bug", "workflow-hardening"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_label_results_in_empty_set(self):
|
||||||
|
self.issue_labels[626] = [PR_OPEN]
|
||||||
|
summary = self._clear([626])
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
self.assertEqual(self.issue_labels[626], [])
|
||||||
|
self.assertEqual(self.puts, [(626, [])])
|
||||||
|
self.assertTrue(summary["results"][0]["empty_label_set"])
|
||||||
|
|
||||||
|
def test_read_after_write_proof_is_returned(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
summary = self._clear([780])
|
||||||
|
entry = summary["results"][0]
|
||||||
|
self.assertTrue(entry["verified"])
|
||||||
|
self.assertEqual(entry["labels_before"], ["type:bug", PR_OPEN])
|
||||||
|
self.assertEqual(entry["labels_after"], ["type:bug"])
|
||||||
|
self.assertEqual(entry["verification"]["observed_labels"], ["type:bug"])
|
||||||
|
|
||||||
|
def test_repeated_cleanup_is_harmless(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
first = self._clear([780])
|
||||||
|
second = self._clear([780], reason=tplc.RETRY_RECOVERY)
|
||||||
|
third = self._clear([780], reason=tplc.RETRY_RECOVERY)
|
||||||
|
self.assertTrue(first["clean"] and second["clean"] and third["clean"])
|
||||||
|
self.assertEqual(second["already_absent"], [780])
|
||||||
|
self.assertEqual(third["already_absent"], [780])
|
||||||
|
# Exactly one mutation across three calls.
|
||||||
|
self.assertEqual(len(self.puts), 1)
|
||||||
|
self.assertEqual(self.issue_labels[780], ["type:bug"])
|
||||||
|
|
||||||
|
def test_noop_path_never_reads_the_label_inventory(self):
|
||||||
|
self.issue_labels[780] = ["type:bug"]
|
||||||
|
with patch("mcp_server._repo_label_id_map") as mock_map:
|
||||||
|
summary = self._clear([780])
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
mock_map.assert_not_called()
|
||||||
|
|
||||||
|
def test_duplicate_issue_numbers_are_collapsed(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
summary = self._clear([780, 780, "780"])
|
||||||
|
self.assertEqual(summary["checked"], [780])
|
||||||
|
self.assertEqual(len(self.puts), 1)
|
||||||
|
|
||||||
|
def test_no_issue_numbers_is_a_clean_noop(self):
|
||||||
|
summary = self._clear([])
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
self.assertEqual(summary["checked"], [])
|
||||||
|
|
||||||
|
def test_failed_mutation_is_reported_not_swallowed(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
|
||||||
|
def boom(*_a, **_kw):
|
||||||
|
raise RuntimeError("gitea exploded")
|
||||||
|
|
||||||
|
with patch("mcp_server._put_issue_label_names", side_effect=boom):
|
||||||
|
summary = self._clear([780])
|
||||||
|
self.assertFalse(summary["clean"])
|
||||||
|
self.assertEqual(summary["failed"], [780])
|
||||||
|
self.assertTrue(summary["safe_next_action"])
|
||||||
|
self.assertIn(PR_OPEN, self.issue_labels[780])
|
||||||
|
|
||||||
|
def test_residual_label_after_write_fails_verification(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
real_api = self._api
|
||||||
|
|
||||||
|
# Simulate a write that reports success but leaves the label behind.
|
||||||
|
def sticky(method, url, auth, payload=None):
|
||||||
|
if method == "PUT" and url.endswith("/labels"):
|
||||||
|
num = int(url.rsplit("/issues/", 1)[1].split("/")[0])
|
||||||
|
self.puts.append((num, payload["labels"]))
|
||||||
|
return [_lb("type:bug", 1), _lb(PR_OPEN, 4)]
|
||||||
|
return real_api(method, url, auth, payload)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=sticky):
|
||||||
|
summary = self._clear([780])
|
||||||
|
self.assertFalse(summary["clean"])
|
||||||
|
self.assertEqual(summary["failed"], [780])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Terminal workflow paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestTerminalPathsUseTheSharedRule(_ExecutorHarness):
|
||||||
|
"""Merge, close-without-merge, supersession and already-landed."""
|
||||||
|
|
||||||
|
def test_merge_path_clears_the_label_for_linked_issues(self):
|
||||||
|
self.issue_labels[780] = ["type:bug", PR_OPEN]
|
||||||
|
merged_pr = {
|
||||||
|
"title": "fix: terminal label cleanup",
|
||||||
|
"body": "Closes #780",
|
||||||
|
"head": {"ref": "fix/issue-780-terminal-pr-open-label-cleanup"},
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"mcp_server.release_in_progress_label", return_value={780: "released"}
|
||||||
|
):
|
||||||
|
result = mcp_server.cleanup_in_progress_for_pr(
|
||||||
|
merged_pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
cleanup = result["pr_open_label_cleanup"]
|
||||||
|
self.assertTrue(cleanup["clean"])
|
||||||
|
self.assertEqual(cleanup["terminal_reason"], tplc.MERGED)
|
||||||
|
self.assertEqual(self.issue_labels[780], ["type:bug"])
|
||||||
|
|
||||||
|
def test_close_without_merge_clears_the_label(self):
|
||||||
|
self.issue_labels[781] = ["type:bug", PR_OPEN, "leases"]
|
||||||
|
closed_pr = {
|
||||||
|
"title": "chore: abandoned",
|
||||||
|
"body": "Closes #781",
|
||||||
|
"head": {"ref": "chore/issue-781-abandoned"},
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"mcp_server.release_in_progress_label", return_value={781: "released"}
|
||||||
|
):
|
||||||
|
result = mcp_server.cleanup_in_progress_for_pr(
|
||||||
|
closed_pr,
|
||||||
|
"prgs",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
terminal_reason=tplc.CLOSED_WITHOUT_MERGE,
|
||||||
|
)
|
||||||
|
cleanup = result["pr_open_label_cleanup"]
|
||||||
|
self.assertTrue(cleanup["clean"])
|
||||||
|
self.assertEqual(cleanup["terminal_reason"], tplc.CLOSED_WITHOUT_MERGE)
|
||||||
|
self.assertEqual(self.issue_labels[781], ["type:bug", "leases"])
|
||||||
|
|
||||||
|
def test_pr_without_linked_issue_reports_an_empty_cleanup(self):
|
||||||
|
pr = {"title": "chore: no link", "body": "", "head": {"ref": "chore/none"}}
|
||||||
|
result = mcp_server.cleanup_in_progress_for_pr(
|
||||||
|
pr, "prgs", None, None, None, terminal_reason=tplc.MERGED
|
||||||
|
)
|
||||||
|
self.assertEqual(result["cleanup_status"], "no linked issue found")
|
||||||
|
self.assertTrue(result["pr_open_label_cleanup"]["clean"])
|
||||||
|
self.assertEqual(result["pr_open_label_cleanup"]["checked"], [])
|
||||||
|
|
||||||
|
def test_supersession_reason_is_recorded(self):
|
||||||
|
self.issue_labels[600] = [PR_OPEN, "type:bug"]
|
||||||
|
summary = self._clear([600], reason=tplc.SUPERSEDED)
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
self.assertEqual(summary["terminal_reason"], tplc.SUPERSEDED)
|
||||||
|
self.assertEqual(self.issue_labels[600], ["type:bug"])
|
||||||
|
|
||||||
|
def test_already_landed_reconciliation_reason_is_recorded(self):
|
||||||
|
self.issue_labels[601] = [PR_OPEN]
|
||||||
|
summary = self._clear([601], reason=tplc.ALREADY_LANDED)
|
||||||
|
self.assertTrue(summary["clean"])
|
||||||
|
self.assertEqual(summary["terminal_reason"], tplc.ALREADY_LANDED)
|
||||||
|
self.assertEqual(self.issue_labels[601], [])
|
||||||
|
|
||||||
|
def test_issue_780_regression_stale_label_survived_every_terminal_path(self):
|
||||||
|
"""Regression for the observed leak.
|
||||||
|
|
||||||
|
Before the fix each terminal path finished without touching
|
||||||
|
``status:pr-open``, so the audit found closed issues still carrying it.
|
||||||
|
Every path now routes through the one shared rule and leaves nothing
|
||||||
|
behind — while preserving each issue's other labels.
|
||||||
|
"""
|
||||||
|
stale = {
|
||||||
|
626: (["type:bug", PR_OPEN], tplc.CONTROLLER_CLOSURE),
|
||||||
|
772: (["workflow-hardening", PR_OPEN], tplc.MERGED),
|
||||||
|
768: ([PR_OPEN], tplc.CLOSED_WITHOUT_MERGE),
|
||||||
|
758: (["leases", PR_OPEN, "type:bug"], tplc.SUPERSEDED),
|
||||||
|
755: (["status:done", PR_OPEN], tplc.ALREADY_LANDED),
|
||||||
|
}
|
||||||
|
for number, (labels, _reason) in stale.items():
|
||||||
|
self.issue_labels[number] = list(labels)
|
||||||
|
|
||||||
|
for number, (_labels, reason) in stale.items():
|
||||||
|
summary = self._clear([number], reason=reason)
|
||||||
|
self.assertTrue(summary["clean"], msg=f"issue #{number}")
|
||||||
|
|
||||||
|
audit = tplc.detect_residual_pr_open(
|
||||||
|
[
|
||||||
|
{"number": num, "state": "closed", "labels": names}
|
||||||
|
for num, names in self.issue_labels.items()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertTrue(audit["clean"])
|
||||||
|
self.assertEqual(audit["residual_count"], 0)
|
||||||
|
# Unrelated labels survived every path.
|
||||||
|
self.assertEqual(self.issue_labels[626], ["type:bug"])
|
||||||
|
self.assertEqual(self.issue_labels[772], ["workflow-hardening"])
|
||||||
|
self.assertEqual(self.issue_labels[768], [])
|
||||||
|
self.assertEqual(self.issue_labels[758], ["leases", "type:bug"])
|
||||||
|
self.assertEqual(self.issue_labels[755], ["status:done"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Controller closure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestControllerClosure(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_close_issue_clears_label_before_closing_and_validates(self):
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_clear(numbers, *_a, **kwargs):
|
||||||
|
calls.append(f"clear:{kwargs['terminal_reason']}")
|
||||||
|
return {
|
||||||
|
"label": PR_OPEN,
|
||||||
|
"clean": True,
|
||||||
|
"checked": list(numbers),
|
||||||
|
"removed": list(numbers),
|
||||||
|
"already_absent": [],
|
||||||
|
"failed": [],
|
||||||
|
"results": [],
|
||||||
|
"reasons": [],
|
||||||
|
"safe_next_action": "",
|
||||||
|
"terminal_reason": kwargs["terminal_reason"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_api(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
calls.append("patch:closed")
|
||||||
|
return {"state": "closed"}
|
||||||
|
return {"labels": [{"name": "type:bug"}]}
|
||||||
|
|
||||||
|
with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
|
||||||
|
patch("mcp_server.api_request", side_effect=fake_api), \
|
||||||
|
patch("mcp_server._profile_permission_block", return_value=None), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server.release_in_progress_label", return_value={}), \
|
||||||
|
patch("mcp_server._resolve", return_value=("h", "o", "r")), \
|
||||||
|
patch("mcp_server._auth", return_value=FAKE_AUTH), \
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False):
|
||||||
|
result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
# Cleanup precedes the state change: closing first would bake in the leak.
|
||||||
|
self.assertEqual(calls[0], f"clear:{tplc.CONTROLLER_CLOSURE}")
|
||||||
|
self.assertIn("patch:closed", calls)
|
||||||
|
self.assertTrue(result["terminal_label_validation"]["clean"])
|
||||||
|
|
||||||
|
def test_close_issue_fails_closed_when_cleanup_cannot_complete(self):
|
||||||
|
def fake_clear(numbers, *_a, **kwargs):
|
||||||
|
return {
|
||||||
|
"label": PR_OPEN,
|
||||||
|
"clean": False,
|
||||||
|
"checked": list(numbers),
|
||||||
|
"removed": [],
|
||||||
|
"already_absent": [],
|
||||||
|
"failed": list(numbers),
|
||||||
|
"results": [],
|
||||||
|
"reasons": ["label replacement failed: boom"],
|
||||||
|
"safe_next_action": "retry",
|
||||||
|
"terminal_reason": kwargs["terminal_reason"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def fail_on_patch(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
raise AssertionError("issue must not be closed when cleanup failed")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \
|
||||||
|
patch("mcp_server.api_request", side_effect=fail_on_patch), \
|
||||||
|
patch("mcp_server._profile_permission_block", return_value=None), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._resolve", return_value=("h", "o", "r")), \
|
||||||
|
patch("mcp_server._auth", return_value=FAKE_AUTH), \
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False):
|
||||||
|
result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs")
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("#780", result["message"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Capability wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestCapabilityWiring(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_task_is_registered_with_label_authority(self):
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("cleanup_terminal_pr_labels"),
|
||||||
|
"gitea.issue.comment",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("cleanup_terminal_pr_labels"),
|
||||||
|
"author",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.tool_required_permission(
|
||||||
|
"gitea_cleanup_terminal_pr_labels"
|
||||||
|
),
|
||||||
|
"gitea.issue.comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recovery_tool_rejects_an_unknown_reason_without_mutating(self):
|
||||||
|
with patch("mcp_server._profile_permission_block", return_value=None), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server.clear_pr_open_label") as mock_clear:
|
||||||
|
result = mcp_server.gitea_cleanup_terminal_pr_labels(
|
||||||
|
issue_numbers=[780], terminal_reason="sometime", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["clean"])
|
||||||
|
mock_clear.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""Hermetic tests for workflow dashboard (#605).
|
||||||
|
|
||||||
|
Covers terminal-blocked queue shapes in the spirit of #593/#592/#587 where an
|
||||||
|
active terminal-review lock must suppress other PRs as safe review/merge work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from allocator_service import WorkCandidate
|
||||||
|
from workflow_dashboard import (
|
||||||
|
DASHBOARD_VERSION,
|
||||||
|
build_workflow_dashboard,
|
||||||
|
format_human_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(
|
||||||
|
number: int,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
labels: tuple[str, ...] = ("status:ready",),
|
||||||
|
priority: int = 20,
|
||||||
|
blocked: bool = False,
|
||||||
|
dependency_unmet: bool = False,
|
||||||
|
dependency_reason: str | None = None,
|
||||||
|
claimed: bool = False,
|
||||||
|
) -> WorkCandidate:
|
||||||
|
return WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=number,
|
||||||
|
title=title or f"issue {number}",
|
||||||
|
labels=labels,
|
||||||
|
priority=priority,
|
||||||
|
blocked=blocked,
|
||||||
|
dependency_unmet=dependency_unmet,
|
||||||
|
dependency_reason=dependency_reason,
|
||||||
|
already_claimed_elsewhere=claimed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pr(
|
||||||
|
number: int,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
head_sha: str = "abc123",
|
||||||
|
request_changes: bool = False,
|
||||||
|
approved: bool = False,
|
||||||
|
mergeable: bool = False,
|
||||||
|
contaminated: bool = False,
|
||||||
|
approval_stale: bool = False,
|
||||||
|
priority: int = 5,
|
||||||
|
) -> WorkCandidate:
|
||||||
|
return WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=number,
|
||||||
|
title=title or f"pr {number}",
|
||||||
|
head_sha=head_sha,
|
||||||
|
request_changes_current_head=request_changes,
|
||||||
|
approval_on_current_head=approved,
|
||||||
|
mergeable=mergeable,
|
||||||
|
approval_contaminated=contaminated,
|
||||||
|
approval_stale=approval_stale,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowDashboard(unittest.TestCase):
|
||||||
|
def test_version_and_read_only_payload(self):
|
||||||
|
snap = build_workflow_dashboard(
|
||||||
|
candidates=[_issue(605)],
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
payload = snap.as_dict()
|
||||||
|
self.assertTrue(payload["read_only"])
|
||||||
|
self.assertEqual(payload["dashboard_version"], DASHBOARD_VERSION)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertTrue(payload["inventory_complete"])
|
||||||
|
self.assertIn("human_summary", payload)
|
||||||
|
|
||||||
|
def test_never_marks_blocked_as_safe(self):
|
||||||
|
candidates = [
|
||||||
|
_issue(10, blocked=True, labels=("status:blocked",)),
|
||||||
|
_issue(11, dependency_unmet=True, dependency_reason="depends on #9"),
|
||||||
|
_issue(12, claimed=True),
|
||||||
|
_issue(605, labels=("status:ready",)),
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(candidates=candidates)
|
||||||
|
blocked_numbers = {e.number for e in snap.blocked_items}
|
||||||
|
self.assertIn(10, blocked_numbers)
|
||||||
|
self.assertIn(11, blocked_numbers)
|
||||||
|
self.assertIn(12, blocked_numbers)
|
||||||
|
for entry in snap.blocked_items:
|
||||||
|
self.assertFalse(entry.as_dict()["is_safe"])
|
||||||
|
self.assertEqual(entry.safe_for_roles, ())
|
||||||
|
self.assertIsNotNone(entry.block_reason)
|
||||||
|
|
||||||
|
author = snap.next_safe_by_role["author"]
|
||||||
|
self.assertEqual(author.status, "safe")
|
||||||
|
self.assertEqual(author.target_number, 605)
|
||||||
|
self.assertNotIn(author.target_number, blocked_numbers)
|
||||||
|
summary = format_human_summary(snap)
|
||||||
|
self.assertIn("NOT safe", summary)
|
||||||
|
self.assertIn("issue#10", summary.replace(" ", ""))
|
||||||
|
|
||||||
|
def test_author_prefers_oldest_ready_issue(self):
|
||||||
|
candidates = [
|
||||||
|
_issue(620, labels=("status:ready",)),
|
||||||
|
_issue(605, labels=("status:ready",)),
|
||||||
|
_issue(610, labels=("status:ready",)),
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(candidates=candidates)
|
||||||
|
author = snap.next_safe_by_role["author"]
|
||||||
|
self.assertEqual(author.status, "safe")
|
||||||
|
self.assertEqual(author.target_number, 605)
|
||||||
|
self.assertIn("gitea_allocate_next_work", author.prompt)
|
||||||
|
self.assertIn("role='author'", author.prompt)
|
||||||
|
|
||||||
|
def test_review_and_merge_ready_buckets(self):
|
||||||
|
candidates = [
|
||||||
|
_pr(100, head_sha="r1"), # review-ready
|
||||||
|
_pr(101, approved=True, mergeable=True, head_sha="m1", priority=8),
|
||||||
|
_pr(102, request_changes=True, head_sha="a1", priority=10),
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(candidates=candidates)
|
||||||
|
self.assertEqual([e.number for e in snap.review_ready_prs], [100])
|
||||||
|
self.assertEqual([e.number for e in snap.merge_ready_prs], [101])
|
||||||
|
self.assertEqual([e.number for e in snap.author_remediation], [102])
|
||||||
|
|
||||||
|
reviewer = snap.next_safe_by_role["reviewer"]
|
||||||
|
self.assertEqual(reviewer.status, "safe")
|
||||||
|
self.assertEqual(reviewer.target_number, 100)
|
||||||
|
self.assertEqual(reviewer.head_sha, "r1")
|
||||||
|
|
||||||
|
merger = snap.next_safe_by_role["merger"]
|
||||||
|
self.assertEqual(merger.status, "safe")
|
||||||
|
self.assertEqual(merger.target_number, 101)
|
||||||
|
self.assertEqual(merger.head_sha, "m1")
|
||||||
|
|
||||||
|
author = snap.next_safe_by_role["author"]
|
||||||
|
self.assertEqual(author.status, "safe")
|
||||||
|
self.assertEqual(author.target_number, 102)
|
||||||
|
|
||||||
|
def test_terminal_lock_blocks_other_prs_as_safe(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
"""#593/#592/#587-style: terminal lock ⇒ other PRs are not safe."""
|
||||||
|
candidates = [
|
||||||
|
_pr(587, head_sha="deadbeef", priority=5),
|
||||||
|
_pr(592, approved=True, mergeable=True, head_sha="cafebabe", priority=8),
|
||||||
|
_pr(593, head_sha="terminalhead", priority=9),
|
||||||
|
_issue(605, labels=("status:ready",)),
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(
|
||||||
|
candidates=candidates,
|
||||||
|
terminal_pr=593,
|
||||||
|
terminal_lock={"terminal_pr": 593, "active": True, "state": "locked"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Non-terminal PRs must appear blocked, never in safe buckets.
|
||||||
|
blocked_prs = {
|
||||||
|
e.number for e in snap.blocked_items if e.kind == "pr"
|
||||||
|
}
|
||||||
|
self.assertIn(587, blocked_prs)
|
||||||
|
self.assertIn(592, blocked_prs)
|
||||||
|
self.assertNotIn(593, blocked_prs) # terminal PR itself may still be routeable
|
||||||
|
|
||||||
|
# Terminal PR itself may remain review-ready; others must not.
|
||||||
|
self.assertEqual([e.number for e in snap.review_ready_prs], [593])
|
||||||
|
self.assertEqual(snap.merge_ready_prs, [])
|
||||||
|
self.assertNotIn(587, [e.number for e in snap.review_ready_prs])
|
||||||
|
self.assertNotIn(592, [e.number for e in snap.merge_ready_prs])
|
||||||
|
|
||||||
|
for entry in snap.blocked_items:
|
||||||
|
if entry.number in (587, 592):
|
||||||
|
self.assertIn("terminal-review lock", entry.block_reason or "")
|
||||||
|
self.assertEqual(entry.safe_for_roles, ())
|
||||||
|
self.assertFalse(entry.as_dict()["is_safe"])
|
||||||
|
|
||||||
|
reviewer = snap.next_safe_by_role["reviewer"]
|
||||||
|
# Reviewer may only target the terminal PR — never 587/592.
|
||||||
|
self.assertEqual(reviewer.status, "safe")
|
||||||
|
self.assertEqual(reviewer.target_number, 593)
|
||||||
|
self.assertEqual(reviewer.head_sha, "terminalhead")
|
||||||
|
self.assertNotEqual(reviewer.target_number, 587)
|
||||||
|
self.assertNotEqual(reviewer.target_number, 592)
|
||||||
|
|
||||||
|
merger = snap.next_safe_by_role["merger"]
|
||||||
|
# Merge-ready #592 is NOT safe while terminal lock is on #593.
|
||||||
|
self.assertNotEqual(merger.target_number, 592)
|
||||||
|
self.assertIn("593", merger.prompt)
|
||||||
|
self.assertIn(
|
||||||
|
merger.status,
|
||||||
|
("blocked_terminal", "idle", "safe"),
|
||||||
|
)
|
||||||
|
if merger.status == "safe":
|
||||||
|
self.assertEqual(merger.target_number, 593)
|
||||||
|
|
||||||
|
# Author issue work remains visible (issues are not terminal-blocked).
|
||||||
|
author = snap.next_safe_by_role["author"]
|
||||||
|
self.assertEqual(author.status, "safe")
|
||||||
|
self.assertEqual(author.target_number, 605)
|
||||||
|
|
||||||
|
summary = format_human_summary(snap)
|
||||||
|
self.assertIn("Terminal review lock: ACTIVE on PR #593", summary)
|
||||||
|
self.assertIn("Do not treat other open PRs as safe", summary)
|
||||||
|
|
||||||
|
def test_incomplete_inventory_fails_closed(self):
|
||||||
|
snap = build_workflow_dashboard(
|
||||||
|
candidates=[_issue(605)],
|
||||||
|
inventory_complete=False,
|
||||||
|
inventory_reasons=["page truncated"],
|
||||||
|
)
|
||||||
|
payload = snap.as_dict()
|
||||||
|
self.assertFalse(payload["inventory_complete"])
|
||||||
|
self.assertEqual(payload["review_ready_prs"], [])
|
||||||
|
self.assertEqual(payload["merge_ready_prs"], [])
|
||||||
|
for action in snap.next_safe_by_role.values():
|
||||||
|
self.assertEqual(action.status, "none")
|
||||||
|
self.assertIsNone(action.target_number)
|
||||||
|
self.assertIn("inventory incomplete", action.prompt.lower())
|
||||||
|
self.assertFalse(action.as_dict()["is_safe"])
|
||||||
|
|
||||||
|
def test_leases_partition_active_vs_stale(self):
|
||||||
|
leases = [
|
||||||
|
{"lease_id": "L1", "role": "author", "status": "active", "work_number": 605},
|
||||||
|
{"lease_id": "L2", "role": "reviewer", "status": "expired", "work_number": 99},
|
||||||
|
{"lease_id": "L3", "role": "merger", "stale": True, "work_number": 88},
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(candidates=[], leases=leases)
|
||||||
|
self.assertEqual(len(snap.active_leases_by_role["author"]), 1)
|
||||||
|
self.assertEqual(len(snap.stale_or_expired_leases), 2)
|
||||||
|
|
||||||
|
def test_discussion_and_controller_needed(self):
|
||||||
|
candidates = [
|
||||||
|
_issue(1, labels=("discussion", "type:discussion")),
|
||||||
|
_pr(2, contaminated=True, head_sha="x"),
|
||||||
|
]
|
||||||
|
snap = build_workflow_dashboard(candidates=candidates)
|
||||||
|
self.assertEqual([e.number for e in snap.discussion_issues], [1])
|
||||||
|
self.assertTrue(any(e.number == 2 for e in snap.controller_needed))
|
||||||
|
recon = snap.next_safe_by_role["reconciler"]
|
||||||
|
self.assertEqual(recon.status, "safe")
|
||||||
|
self.assertEqual(recon.target_number, 2)
|
||||||
|
|
||||||
|
def test_human_summary_includes_exact_prompts(self):
|
||||||
|
snap = build_workflow_dashboard(
|
||||||
|
candidates=[_issue(605)],
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
text = format_human_summary(snap)
|
||||||
|
self.assertIn("gitea_allocate_next_work", text)
|
||||||
|
self.assertIn("prgs/Scaled-Tech-Consulting/Gitea-Tools", text)
|
||||||
|
self.assertIn("never self-selects", text.lower())
|
||||||
|
self.assertIn("Primary next:", text)
|
||||||
|
|
||||||
|
def test_missing_pr_head_sha_is_blocked(self):
|
||||||
|
candidates = [_pr(50, head_sha="")]
|
||||||
|
# WorkCandidate allows empty head; dashboard must block it.
|
||||||
|
c = candidates[0]
|
||||||
|
c.head_sha = ""
|
||||||
|
snap = build_workflow_dashboard(candidates=[c])
|
||||||
|
self.assertEqual(len(snap.blocked_items), 1)
|
||||||
|
self.assertIn("head_sha", snap.blocked_items[0].block_reason or "")
|
||||||
|
self.assertEqual(snap.review_ready_prs, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -304,6 +304,193 @@ Who/what acts next:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Stable control runtime states (#615) ─────────────────────────────────────
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"runtime_healthy",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Runtime check — stable control runtime healthy
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- none — no server-side state changed
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- none
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
[THREAD STATE LEDGER] Runtime — stable control runtime healthy
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Runtime mode: stable-control
|
||||||
|
- Runtime git SHA: {HEAD_SHA}
|
||||||
|
- Server-side decision state: no server-side state changed
|
||||||
|
- Local verdict/state: runtime reported real_mutations_allowed=true
|
||||||
|
- Latest known validation: gitea_get_runtime_context read in this session
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- nothing; this is a read-only runtime observation
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: no blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: author
|
||||||
|
- Required action: proceed with the allocated workflow phase
|
||||||
|
- Do not do: restart or relaunch the stable runtime
|
||||||
|
- Resume from: gitea_workflow_dashboard
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"transport_flap_recovered",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Runtime check — transport flap recovered
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- none — no server-side state changed
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- environment/tooling blocker: MCP transport dropped mid-session and recovered
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
[THREAD STATE LEDGER] Runtime — transport flap recovered, namespaces re-proven
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Runtime mode: stable-control
|
||||||
|
- Runtime git SHA: {HEAD_SHA}
|
||||||
|
- Server-side decision state: no server-side state changed
|
||||||
|
- Local verdict/state: all four namespaces re-proven after the flap
|
||||||
|
- Latest known validation: whoami + runtime context + capability resolve per namespace
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- author, reviewer, merger, and reconciler namespaces each re-proven independently
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: no blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: author
|
||||||
|
- Required action: resume the interrupted workflow phase from its last durable state
|
||||||
|
- Do not do: treat author proof as proof of the other namespaces
|
||||||
|
- Resume from: the phase handoff that preceded the flap
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"namespace_not_yet_reproven",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Runtime check — reviewer namespace not re-proven
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- none — no server-side state changed
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- environment/tooling blocker: reviewer namespace not re-proven since the transport flap
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
[THREAD STATE LEDGER] Runtime — reviewer namespace not re-proven after flap
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Runtime mode: stable-control
|
||||||
|
- Runtime git SHA: {HEAD_SHA}
|
||||||
|
- Server-side decision state: no server-side state changed
|
||||||
|
- Local verdict/state: reviewer namespace unproven; mutation gate fails closed
|
||||||
|
- Latest known validation: author namespace re-proven; reviewer not attempted
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- reviewer mutations blocked with namespace_not_reproven_after_flap
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: environment/tooling blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: reviewer
|
||||||
|
- Required action: run whoami, runtime context, and capability resolve in the reviewer namespace
|
||||||
|
- Do not do: substitute author proof for reviewer proof
|
||||||
|
- Resume from: docs/stable-runtime-promotion-runbook.md section 5
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"promotion_completed",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Runtime promotion — completed
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- gitea_create_issue_comment on #615 with the promotion record
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- none
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
[THREAD STATE LEDGER] Runtime — promotion completed and re-proven
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Runtime mode: stable-control
|
||||||
|
- Runtime git SHA: {HEAD_SHA}
|
||||||
|
- Server-side decision state: server-side state changed
|
||||||
|
- Local verdict/state: promotion record carries every required field
|
||||||
|
- Latest known validation: assess_promotion_record valid=true; all namespaces re-proven
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- stable control runtime advanced to the promoted SHA and reloaded by the operator
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: no blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: author
|
||||||
|
- Required action: resume normal workflow phases on the promoted runtime
|
||||||
|
- Do not do: promote again without a fresh record
|
||||||
|
- Resume from: docs/stable-runtime-promotion-runbook.md section 4
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"rollback_required",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Runtime promotion — rollback required
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- gitea_create_issue_comment on #615 with the rollback evidence
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- environment/tooling blocker: promoted runtime unhealthy, rollback required
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
[THREAD STATE LEDGER] Runtime — promoted runtime unhealthy, rollback required
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Runtime mode: unknown
|
||||||
|
- Runtime git SHA: {HEAD_SHA}
|
||||||
|
- Server-side decision state: no server-side state changed after the promotion record
|
||||||
|
- Local verdict/state: promoted runtime failed namespace health; mutations blocked
|
||||||
|
- Latest known validation: namespace health probe reported EOF after reload
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- all PR/review/merge work stopped pending rollback to the previous runtime SHA
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: environment/tooling blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: controller
|
||||||
|
- Required action: operator rolls back to the previous runtime SHA and re-proves every namespace
|
||||||
|
- Do not do: route around the unhealthy runtime or mutate from a dev/test runtime
|
||||||
|
- Resume from: docs/stable-runtime-promotion-runbook.md section 6
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
EXAMPLES.append(
|
EXAMPLES.append(
|
||||||
_example(
|
_example(
|
||||||
"duplicate_canonicalization_blocker",
|
"duplicate_canonicalization_blocker",
|
||||||
@@ -336,6 +523,49 @@ Who/what acts next:
|
|||||||
- Required action: implement #507 two-comment validator
|
- Required action: implement #507 two-comment validator
|
||||||
- Do not do: recreate duplicate CTH issue
|
- Do not do: recreate duplicate CTH issue
|
||||||
- Resume from: issue #507 body
|
- Resume from: issue #507 body
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES.append(
|
||||||
|
_example(
|
||||||
|
"bound_worktree_missing_blocker",
|
||||||
|
"""
|
||||||
|
[CONTROLLER HANDOFF] Issue #618 — author mutation blocked
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- none — no server-side state changed
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- environment/tooling blocker: bound worktree missing; operator must recreate or repoint the worktree and reconnect
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
[THREAD STATE LEDGER] Issue #618 — author worktree binding unhealthy
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- Issue state: open
|
||||||
|
- Server-side decision state: no server-side state changed
|
||||||
|
- Local verdict/state: author mutation tools fail closed consistently
|
||||||
|
- Latest known validation: runtime context reports workspace_healthy=false
|
||||||
|
- Role/profile: prgs-author
|
||||||
|
- Configured worktree path: branches/mcp-author-clean-ns (via GITEA_AUTHOR_WORKTREE)
|
||||||
|
- path_exists: false
|
||||||
|
- in_git_worktree_list: false
|
||||||
|
- inspected_git_root: null
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- nothing server-side; local env still points at a deleted role-bound worktree
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: environment/tooling blocker
|
||||||
|
- Blocker detail: bound worktree missing; operator must recreate or repoint the worktree and reconnect
|
||||||
|
- create_issue and create_issue_comment (and other author mutations) agree: fail closed before API mutation
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: operator
|
||||||
|
- Required action: 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), keep control checkout clean on master, reconnect the author MCP session, then re-run the mutation
|
||||||
|
- Do not do: retry mutations hoping create_issue_comment will still work while create_issue blocks; do not fall back to the control checkout or master
|
||||||
|
- Resume from: healthy author worktree binding + gitea_whoami + gitea_resolve_task_capability
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
"""Read-only workflow dashboard for live queue / lease / next-safe-action (#605).
|
||||||
|
|
||||||
|
Builds a machine-readable + human-readable operational view so humans and LLMs
|
||||||
|
can see what is safe to work on without reconstructing state from comments.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
* Read-only: never assigns work. Assignment still goes through
|
||||||
|
``gitea_allocate_next_work`` (#600).
|
||||||
|
* Never present blocked / terminal-locked / dependency-unmet items as safe.
|
||||||
|
* Prefer pure classification so unit tests can inject inventory (including
|
||||||
|
terminal-blocked queues from #593/#592/#587-style scenarios).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from allocator_service import (
|
||||||
|
ROLE_AUTHOR,
|
||||||
|
ROLE_CONTROLLER,
|
||||||
|
ROLE_MERGER,
|
||||||
|
ROLE_RECONCILER,
|
||||||
|
ROLE_REVIEWER,
|
||||||
|
OWNERSHIP_FOREIGN,
|
||||||
|
OWNERSHIP_UNKNOWN,
|
||||||
|
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||||
|
WorkCandidate,
|
||||||
|
classify_claim_ownership,
|
||||||
|
classify_skip,
|
||||||
|
expected_role_for_candidate,
|
||||||
|
sort_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
DASHBOARD_VERSION = "1.0.0-issue-605"
|
||||||
|
|
||||||
|
# Roles the dashboard surfaces next-safe prompts for.
|
||||||
|
DASHBOARD_ROLES: tuple[str, ...] = (
|
||||||
|
ROLE_AUTHOR,
|
||||||
|
ROLE_REVIEWER,
|
||||||
|
ROLE_MERGER,
|
||||||
|
ROLE_RECONCILER,
|
||||||
|
ROLE_CONTROLLER,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exact operator prompts (fill-in tokens only — no self-selection).
|
||||||
|
PROMPT_AUTHOR = (
|
||||||
|
"AUTHOR session: call gitea_allocate_next_work(apply=true, role='author') "
|
||||||
|
"for {remote}/{org}/{repo}, then implement only the assigned issue under "
|
||||||
|
"branches/ and open/update its PR. Do not self-select outside the allocator."
|
||||||
|
)
|
||||||
|
PROMPT_REVIEWER = (
|
||||||
|
"REVIEWER session: call gitea_allocate_next_work(apply=true, role='reviewer') "
|
||||||
|
"for {remote}/{org}/{repo}, pin the assigned PR head SHA, submit exactly one "
|
||||||
|
"formal review verdict for that head. Do not merge."
|
||||||
|
)
|
||||||
|
PROMPT_MERGER = (
|
||||||
|
"MERGER session: call gitea_allocate_next_work(apply=true, role='merger') "
|
||||||
|
"for {remote}/{org}/{repo}, reassess the assigned approved head, and merge "
|
||||||
|
"only that exact head via gitea_merge_pr. Do not review."
|
||||||
|
)
|
||||||
|
PROMPT_RECONCILER = (
|
||||||
|
"RECONCILER session: call gitea_allocate_next_work(apply=true, role='reconciler') "
|
||||||
|
"for {remote}/{org}/{repo}, then perform only the assigned terminal "
|
||||||
|
"reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
|
||||||
|
)
|
||||||
|
PROMPT_CONTROLLER = (
|
||||||
|
"CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, "
|
||||||
|
"diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule "
|
||||||
|
"exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band."
|
||||||
|
)
|
||||||
|
PROMPT_IDLE = (
|
||||||
|
"IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "
|
||||||
|
"Do not self-select. Re-run gitea_workflow_dashboard on the next cycle."
|
||||||
|
)
|
||||||
|
PROMPT_TERMINAL_BLOCK = (
|
||||||
|
"BLOCKED by terminal-review lock on PR #{terminal_pr} for {remote}/{org}/{repo}. "
|
||||||
|
"Resolve the terminal path for that exact PR before any other review/merge work. "
|
||||||
|
"Do not treat other open PRs as safe."
|
||||||
|
)
|
||||||
|
PROMPT_BLOCKED_ITEM = (
|
||||||
|
"NOT SAFE: {kind}#{number} is blocked ({reason}). Never present as next safe work."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QueueEntry:
|
||||||
|
kind: str
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
expected_role: str
|
||||||
|
safe_for_roles: tuple[str, ...]
|
||||||
|
badges: tuple[str, ...]
|
||||||
|
block_reason: str | None = None
|
||||||
|
head_sha: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"number": self.number,
|
||||||
|
"title": self.title,
|
||||||
|
"expected_role": self.expected_role,
|
||||||
|
"safe_for_roles": list(self.safe_for_roles),
|
||||||
|
"badges": list(self.badges),
|
||||||
|
"block_reason": self.block_reason,
|
||||||
|
"head_sha": self.head_sha,
|
||||||
|
"is_safe": self.block_reason is None and bool(self.safe_for_roles),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoleNextAction:
|
||||||
|
role: str
|
||||||
|
status: str # safe | idle | blocked_terminal | none
|
||||||
|
target_kind: str | None
|
||||||
|
target_number: int | None
|
||||||
|
head_sha: str | None
|
||||||
|
prompt: str
|
||||||
|
reasons: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"role": self.role,
|
||||||
|
"status": self.status,
|
||||||
|
"target_kind": self.target_kind,
|
||||||
|
"target_number": self.target_number,
|
||||||
|
"head_sha": self.head_sha,
|
||||||
|
"prompt": self.prompt,
|
||||||
|
"reasons": list(self.reasons),
|
||||||
|
"is_safe": self.status == "safe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardSnapshot:
|
||||||
|
remote: str
|
||||||
|
org: str
|
||||||
|
repo: str
|
||||||
|
inventory_complete: bool
|
||||||
|
candidate_count: int
|
||||||
|
open_prs: list[QueueEntry] = field(default_factory=list)
|
||||||
|
open_issues: list[QueueEntry] = field(default_factory=list)
|
||||||
|
review_ready_prs: list[QueueEntry] = field(default_factory=list)
|
||||||
|
merge_ready_prs: list[QueueEntry] = field(default_factory=list)
|
||||||
|
author_remediation: list[QueueEntry] = field(default_factory=list)
|
||||||
|
discussion_issues: list[QueueEntry] = field(default_factory=list)
|
||||||
|
blocked_items: list[QueueEntry] = field(default_factory=list)
|
||||||
|
controller_needed: list[QueueEntry] = field(default_factory=list)
|
||||||
|
active_leases_by_role: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
||||||
|
stale_or_expired_leases: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
terminal_review_lock: dict[str, Any] | None = None
|
||||||
|
next_safe_by_role: dict[str, RoleNextAction] = field(default_factory=dict)
|
||||||
|
primary_next_safe_action: RoleNextAction | None = None
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
dashboard_version: str = DASHBOARD_VERSION
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"success": self.inventory_complete and not any(
|
||||||
|
r.startswith("inventory incomplete") for r in self.reasons
|
||||||
|
),
|
||||||
|
"read_only": True,
|
||||||
|
"dashboard_version": self.dashboard_version,
|
||||||
|
"remote": self.remote,
|
||||||
|
"org": self.org,
|
||||||
|
"repo": self.repo,
|
||||||
|
"inventory_complete": self.inventory_complete,
|
||||||
|
"candidate_count": self.candidate_count,
|
||||||
|
"open_pr_queue": [e.as_dict() for e in self.open_prs],
|
||||||
|
"open_issue_queue": [e.as_dict() for e in self.open_issues],
|
||||||
|
"review_ready_prs": [e.as_dict() for e in self.review_ready_prs],
|
||||||
|
"merge_ready_prs": [e.as_dict() for e in self.merge_ready_prs],
|
||||||
|
"author_remediation": [e.as_dict() for e in self.author_remediation],
|
||||||
|
"discussion_issues": [e.as_dict() for e in self.discussion_issues],
|
||||||
|
"blocked_items": [e.as_dict() for e in self.blocked_items],
|
||||||
|
"controller_needed": [e.as_dict() for e in self.controller_needed],
|
||||||
|
"active_leases_by_role": {
|
||||||
|
role: list(items) for role, items in self.active_leases_by_role.items()
|
||||||
|
},
|
||||||
|
"stale_or_expired_leases": list(self.stale_or_expired_leases),
|
||||||
|
"terminal_review_lock": self.terminal_review_lock,
|
||||||
|
"next_safe_by_role": {
|
||||||
|
role: action.as_dict() for role, action in self.next_safe_by_role.items()
|
||||||
|
},
|
||||||
|
"primary_next_safe_action": (
|
||||||
|
self.primary_next_safe_action.as_dict()
|
||||||
|
if self.primary_next_safe_action
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"reasons": list(self.reasons),
|
||||||
|
"human_summary": format_human_summary(self),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_tokens(remote: str, org: str, repo: str) -> dict[str, str]:
|
||||||
|
return {"remote": remote, "org": org, "repo": repo}
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_for_role(
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
terminal_pr: int | None = None,
|
||||||
|
idle: bool = False,
|
||||||
|
) -> str:
|
||||||
|
scope = _scope_tokens(remote, org, repo)
|
||||||
|
if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
|
return PROMPT_TERMINAL_BLOCK.format(terminal_pr=terminal_pr, **scope)
|
||||||
|
if idle:
|
||||||
|
return PROMPT_IDLE.format(role=role, **scope)
|
||||||
|
templates = {
|
||||||
|
ROLE_AUTHOR: PROMPT_AUTHOR,
|
||||||
|
ROLE_REVIEWER: PROMPT_REVIEWER,
|
||||||
|
ROLE_MERGER: PROMPT_MERGER,
|
||||||
|
ROLE_RECONCILER: PROMPT_RECONCILER,
|
||||||
|
ROLE_CONTROLLER: PROMPT_CONTROLLER,
|
||||||
|
}
|
||||||
|
return templates.get(role, PROMPT_CONTROLLER).format(**scope)
|
||||||
|
|
||||||
|
|
||||||
|
def _badges_for_candidate(c: WorkCandidate, *, terminal_pr: int | None) -> tuple[str, ...]:
|
||||||
|
badges: list[str] = []
|
||||||
|
if c.kind == "pr":
|
||||||
|
if c.request_changes_current_head:
|
||||||
|
badges.append("request-changes")
|
||||||
|
if c.approval_on_current_head and c.mergeable:
|
||||||
|
badges.append("merge-ready")
|
||||||
|
elif c.approval_on_current_head and not c.mergeable:
|
||||||
|
badges.append("approved-not-mergeable")
|
||||||
|
if c.approval_stale:
|
||||||
|
badges.append("approval-stale")
|
||||||
|
if c.approval_contaminated:
|
||||||
|
badges.append("contaminated")
|
||||||
|
if not c.approval_on_current_head and not c.request_changes_current_head:
|
||||||
|
badges.append("review-ready")
|
||||||
|
if terminal_pr is not None and c.number == terminal_pr:
|
||||||
|
badges.append("terminal-lock")
|
||||||
|
if terminal_pr is not None and c.number != terminal_pr:
|
||||||
|
badges.append("blocked-by-terminal")
|
||||||
|
else:
|
||||||
|
labels = set(c.labels)
|
||||||
|
if "status:ready" in labels:
|
||||||
|
badges.append("ready")
|
||||||
|
if "status:in-progress" in labels:
|
||||||
|
badges.append("in-progress")
|
||||||
|
if "status:blocked" in labels or c.blocked:
|
||||||
|
badges.append("blocked")
|
||||||
|
if "discussion" in labels or "type:discussion" in labels:
|
||||||
|
badges.append("discussion")
|
||||||
|
if c.dependency_unmet:
|
||||||
|
badges.append("dependency-unmet")
|
||||||
|
if c.already_claimed_elsewhere:
|
||||||
|
badges.append("claimed")
|
||||||
|
if c.blocked:
|
||||||
|
badges.append("blocked")
|
||||||
|
# de-dupe preserve order
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for b in badges:
|
||||||
|
if b not in seen:
|
||||||
|
seen.add(b)
|
||||||
|
out.append(b)
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_for_candidate(
|
||||||
|
c: WorkCandidate,
|
||||||
|
*,
|
||||||
|
terminal_pr: int | None,
|
||||||
|
claim_ownership: str | None = None,
|
||||||
|
) -> QueueEntry:
|
||||||
|
expected = expected_role_for_candidate(c)
|
||||||
|
badges = _badges_for_candidate(c, terminal_pr=terminal_pr)
|
||||||
|
claimed_by_other = claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN)
|
||||||
|
if claimed_by_other and "claimed" not in badges:
|
||||||
|
badges = tuple(list(badges) + ["claimed"])
|
||||||
|
safe_roles: list[str] = []
|
||||||
|
block_reason: str | None = None
|
||||||
|
|
||||||
|
# Global hard blocks (never safe for any worker role).
|
||||||
|
if c.blocked or "status:blocked" in c.labels:
|
||||||
|
block_reason = "status blocked"
|
||||||
|
elif c.dependency_unmet:
|
||||||
|
block_reason = c.dependency_reason or "unmet dependency"
|
||||||
|
elif claimed_by_other:
|
||||||
|
# #765: never advertise another controller's active task as safe work.
|
||||||
|
block_reason = (
|
||||||
|
f"{SKIP_CLAIMED_BY_OTHER_SESSION}: active lease held by another "
|
||||||
|
"controller"
|
||||||
|
)
|
||||||
|
elif c.already_claimed_elsewhere:
|
||||||
|
block_reason = "already claimed elsewhere"
|
||||||
|
elif c.kind == "pr" and not (c.head_sha or "").strip():
|
||||||
|
block_reason = "missing head_sha pin"
|
||||||
|
elif (
|
||||||
|
terminal_pr is not None
|
||||||
|
and c.kind == "pr"
|
||||||
|
and c.number != terminal_pr
|
||||||
|
):
|
||||||
|
# Other PRs remain visible but are not safe for review/merge while a
|
||||||
|
# terminal lock is active (#593/#592/#587-style queue).
|
||||||
|
block_reason = f"active terminal-review lock on PR #{terminal_pr}"
|
||||||
|
|
||||||
|
if block_reason is None:
|
||||||
|
# Safe only for the expected role, and only when classify_skip agrees.
|
||||||
|
skip = classify_skip(
|
||||||
|
c,
|
||||||
|
role=expected,
|
||||||
|
terminal_pr=terminal_pr,
|
||||||
|
claim_ownership=claim_ownership,
|
||||||
|
)
|
||||||
|
if skip is None:
|
||||||
|
safe_roles.append(expected)
|
||||||
|
else:
|
||||||
|
block_reason = skip
|
||||||
|
|
||||||
|
return QueueEntry(
|
||||||
|
kind=c.kind,
|
||||||
|
number=c.number,
|
||||||
|
title=c.title or "",
|
||||||
|
expected_role=expected,
|
||||||
|
safe_for_roles=tuple(safe_roles),
|
||||||
|
badges=badges,
|
||||||
|
block_reason=block_reason,
|
||||||
|
head_sha=c.head_sha,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _partition_leases(
|
||||||
|
leases: Sequence[dict[str, Any]] | None,
|
||||||
|
) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
|
||||||
|
by_role: dict[str, list[dict[str, Any]]] = {r: [] for r in DASHBOARD_ROLES}
|
||||||
|
stale: list[dict[str, Any]] = []
|
||||||
|
for raw in leases or ():
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
role = str(raw.get("role") or raw.get("owner_role") or "unknown").strip().lower()
|
||||||
|
status = str(raw.get("status") or raw.get("lease_status") or "active").strip().lower()
|
||||||
|
entry = dict(raw)
|
||||||
|
if status in ("expired", "stale", "released", "moot") or raw.get("stale") or raw.get(
|
||||||
|
"expired"
|
||||||
|
):
|
||||||
|
stale.append(entry)
|
||||||
|
continue
|
||||||
|
if role in by_role:
|
||||||
|
by_role[role].append(entry)
|
||||||
|
else:
|
||||||
|
by_role.setdefault(role, []).append(entry)
|
||||||
|
return by_role, stale
|
||||||
|
|
||||||
|
|
||||||
|
def _first_safe_for_role(
|
||||||
|
entries: Iterable[QueueEntry],
|
||||||
|
role: str,
|
||||||
|
) -> QueueEntry | None:
|
||||||
|
for entry in entries:
|
||||||
|
if role in entry.safe_for_roles and entry.block_reason is None:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _role_next_action(
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
entries: Sequence[QueueEntry],
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
terminal_pr: int | None,
|
||||||
|
) -> RoleNextAction:
|
||||||
|
# Terminal lock blocks reviewer/merger from non-terminal work.
|
||||||
|
if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
|
terminal_entry = next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in entries
|
||||||
|
if e.kind == "pr" and e.number == terminal_pr and role in e.safe_for_roles
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if terminal_entry is None:
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="blocked_terminal",
|
||||||
|
target_kind="pr",
|
||||||
|
target_number=terminal_pr,
|
||||||
|
head_sha=None,
|
||||||
|
prompt=_prompt_for_role(
|
||||||
|
role,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
terminal_pr=terminal_pr,
|
||||||
|
),
|
||||||
|
reasons=(
|
||||||
|
f"active terminal-review lock on PR #{terminal_pr}; "
|
||||||
|
"no other review/merge target is safe",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="safe",
|
||||||
|
target_kind="pr",
|
||||||
|
target_number=terminal_pr,
|
||||||
|
head_sha=terminal_entry.head_sha,
|
||||||
|
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
|
||||||
|
reasons=(f"terminal-path PR #{terminal_pr} is the only safe target",),
|
||||||
|
)
|
||||||
|
|
||||||
|
if role == ROLE_CONTROLLER:
|
||||||
|
needed = [e for e in entries if e.expected_role == ROLE_CONTROLLER or e.block_reason]
|
||||||
|
if not needed:
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="idle",
|
||||||
|
target_kind=None,
|
||||||
|
target_number=None,
|
||||||
|
head_sha=None,
|
||||||
|
prompt=_prompt_for_role(
|
||||||
|
role, remote=remote, org=org, repo=repo, idle=True
|
||||||
|
),
|
||||||
|
reasons=("no controller-needed items",),
|
||||||
|
)
|
||||||
|
target = needed[0]
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="safe",
|
||||||
|
target_kind=target.kind,
|
||||||
|
target_number=target.number,
|
||||||
|
head_sha=target.head_sha,
|
||||||
|
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
|
||||||
|
reasons=(target.block_reason or "controller diagnosis required",),
|
||||||
|
)
|
||||||
|
|
||||||
|
hit = _first_safe_for_role(entries, role)
|
||||||
|
if hit is None:
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="idle",
|
||||||
|
target_kind=None,
|
||||||
|
target_number=None,
|
||||||
|
head_sha=None,
|
||||||
|
prompt=_prompt_for_role(
|
||||||
|
role, remote=remote, org=org, repo=repo, idle=True
|
||||||
|
),
|
||||||
|
reasons=(f"no safe assignable work for role '{role}'",),
|
||||||
|
)
|
||||||
|
return RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="safe",
|
||||||
|
target_kind=hit.kind,
|
||||||
|
target_number=hit.number,
|
||||||
|
head_sha=hit.head_sha,
|
||||||
|
prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
|
||||||
|
reasons=(f"highest-ranked safe candidate for role '{role}'",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow_dashboard(
|
||||||
|
*,
|
||||||
|
candidates: Sequence[WorkCandidate],
|
||||||
|
remote: str = "prgs",
|
||||||
|
org: str = "Scaled-Tech-Consulting",
|
||||||
|
repo: str = "Gitea-Tools",
|
||||||
|
leases: Sequence[dict[str, Any]] | None = None,
|
||||||
|
terminal_pr: int | None = None,
|
||||||
|
terminal_lock: dict[str, Any] | None = None,
|
||||||
|
inventory_complete: bool = True,
|
||||||
|
inventory_reasons: Sequence[str] | None = None,
|
||||||
|
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
controller_instance_id: str | None = None,
|
||||||
|
) -> DashboardSnapshot:
|
||||||
|
"""Build a full dashboard snapshot from injected inventory (pure).
|
||||||
|
|
||||||
|
*claims* (#765) maps ``(kind, number)`` to the live lease holding that work
|
||||||
|
item. Items claimed by a different controller are never presented as safe
|
||||||
|
next work for this one.
|
||||||
|
"""
|
||||||
|
reasons = [str(r) for r in (inventory_reasons or ()) if str(r).strip()]
|
||||||
|
if not inventory_complete:
|
||||||
|
reasons.append(
|
||||||
|
"inventory incomplete: refuse to present partial queues as complete "
|
||||||
|
"(fail closed, #605/#758)"
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked = sort_candidates(list(candidates))
|
||||||
|
entries = [
|
||||||
|
_entry_for_candidate(
|
||||||
|
c,
|
||||||
|
terminal_pr=terminal_pr,
|
||||||
|
claim_ownership=classify_claim_ownership(
|
||||||
|
(claims or {}).get((c.kind, int(c.number))),
|
||||||
|
session_id=session_id,
|
||||||
|
controller_instance_id=controller_instance_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for c in ranked
|
||||||
|
]
|
||||||
|
|
||||||
|
open_prs = [e for e in entries if e.kind == "pr"]
|
||||||
|
open_issues = [e for e in entries if e.kind == "issue"]
|
||||||
|
review_ready = [
|
||||||
|
e
|
||||||
|
for e in open_prs
|
||||||
|
if "review-ready" in e.badges
|
||||||
|
and e.block_reason is None
|
||||||
|
and ROLE_REVIEWER in e.safe_for_roles
|
||||||
|
]
|
||||||
|
merge_ready = [
|
||||||
|
e
|
||||||
|
for e in open_prs
|
||||||
|
if "merge-ready" in e.badges
|
||||||
|
and e.block_reason is None
|
||||||
|
and ROLE_MERGER in e.safe_for_roles
|
||||||
|
]
|
||||||
|
author_remediation = [
|
||||||
|
e
|
||||||
|
for e in open_prs
|
||||||
|
if "request-changes" in e.badges
|
||||||
|
and e.block_reason is None
|
||||||
|
and ROLE_AUTHOR in e.safe_for_roles
|
||||||
|
]
|
||||||
|
discussion = [
|
||||||
|
e
|
||||||
|
for e in open_issues
|
||||||
|
if "discussion" in e.badges
|
||||||
|
]
|
||||||
|
blocked = [e for e in entries if e.block_reason is not None]
|
||||||
|
controller_needed = [
|
||||||
|
e
|
||||||
|
for e in entries
|
||||||
|
if e.expected_role == ROLE_CONTROLLER
|
||||||
|
or (e.block_reason and "contaminated" in (e.badges or ()))
|
||||||
|
or "contaminated" in e.badges
|
||||||
|
]
|
||||||
|
|
||||||
|
leases_by_role, stale_leases = _partition_leases(leases)
|
||||||
|
|
||||||
|
term_payload = None
|
||||||
|
if terminal_lock is not None:
|
||||||
|
term_payload = dict(terminal_lock)
|
||||||
|
elif terminal_pr is not None:
|
||||||
|
term_payload = {
|
||||||
|
"active": True,
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"state": "locked",
|
||||||
|
}
|
||||||
|
|
||||||
|
next_by_role: dict[str, RoleNextAction] = {}
|
||||||
|
for role in DASHBOARD_ROLES:
|
||||||
|
next_by_role[role] = _role_next_action(
|
||||||
|
role,
|
||||||
|
entries=entries,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
terminal_pr=terminal_pr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Primary next action prefers in-flight PR work, then author issues.
|
||||||
|
primary: RoleNextAction | None = None
|
||||||
|
for role in (ROLE_REVIEWER, ROLE_MERGER, ROLE_AUTHOR, ROLE_RECONCILER, ROLE_CONTROLLER):
|
||||||
|
action = next_by_role[role]
|
||||||
|
if action.status == "safe":
|
||||||
|
primary = action
|
||||||
|
break
|
||||||
|
if primary is None:
|
||||||
|
# Prefer an explicit terminal block signal over generic idle.
|
||||||
|
for role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
|
if next_by_role[role].status == "blocked_terminal":
|
||||||
|
primary = next_by_role[role]
|
||||||
|
break
|
||||||
|
if primary is None:
|
||||||
|
primary = next_by_role[ROLE_AUTHOR]
|
||||||
|
|
||||||
|
# Incomplete inventory: strip all safe flags / never suggest work.
|
||||||
|
if not inventory_complete:
|
||||||
|
for role, action in list(next_by_role.items()):
|
||||||
|
next_by_role[role] = RoleNextAction(
|
||||||
|
role=role,
|
||||||
|
status="none",
|
||||||
|
target_kind=None,
|
||||||
|
target_number=None,
|
||||||
|
head_sha=None,
|
||||||
|
prompt=(
|
||||||
|
f"BLOCKED: inventory incomplete for {remote}/{org}/{repo}; "
|
||||||
|
"do not select work. Re-run after a complete listing."
|
||||||
|
),
|
||||||
|
reasons=tuple(reasons) or ("inventory incomplete",),
|
||||||
|
)
|
||||||
|
primary = next_by_role[ROLE_CONTROLLER]
|
||||||
|
review_ready = []
|
||||||
|
merge_ready = []
|
||||||
|
author_remediation = []
|
||||||
|
|
||||||
|
return DashboardSnapshot(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
inventory_complete=inventory_complete,
|
||||||
|
candidate_count=len(ranked),
|
||||||
|
open_prs=open_prs,
|
||||||
|
open_issues=open_issues,
|
||||||
|
review_ready_prs=review_ready,
|
||||||
|
merge_ready_prs=merge_ready,
|
||||||
|
author_remediation=author_remediation,
|
||||||
|
discussion_issues=discussion,
|
||||||
|
blocked_items=blocked,
|
||||||
|
controller_needed=controller_needed,
|
||||||
|
active_leases_by_role=leases_by_role,
|
||||||
|
stale_or_expired_leases=stale_leases,
|
||||||
|
terminal_review_lock=term_payload,
|
||||||
|
next_safe_by_role=next_by_role,
|
||||||
|
primary_next_safe_action=primary,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_human_summary(snapshot: DashboardSnapshot) -> str:
|
||||||
|
"""Compact human-readable multi-line summary for menus and operators."""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(
|
||||||
|
f"Workflow dashboard v{snapshot.dashboard_version} — "
|
||||||
|
f"{snapshot.remote}/{snapshot.org}/{snapshot.repo}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"Inventory: complete={snapshot.inventory_complete} "
|
||||||
|
f"candidates={snapshot.candidate_count}"
|
||||||
|
)
|
||||||
|
if snapshot.terminal_review_lock:
|
||||||
|
tpr = snapshot.terminal_review_lock.get("terminal_pr")
|
||||||
|
lines.append(f"Terminal review lock: ACTIVE on PR #{tpr}")
|
||||||
|
else:
|
||||||
|
lines.append("Terminal review lock: none")
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
f"Open PRs: {len(snapshot.open_prs)} | Open issues: {len(snapshot.open_issues)}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"Review-ready: {len(snapshot.review_ready_prs)} | "
|
||||||
|
f"Merge-ready: {len(snapshot.merge_ready_prs)} | "
|
||||||
|
f"Author remediation: {len(snapshot.author_remediation)}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"Blocked: {len(snapshot.blocked_items)} | "
|
||||||
|
f"Controller-needed: {len(snapshot.controller_needed)} | "
|
||||||
|
f"Discussion: {len(snapshot.discussion_issues)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
active_counts = {
|
||||||
|
role: len(items)
|
||||||
|
for role, items in snapshot.active_leases_by_role.items()
|
||||||
|
if items
|
||||||
|
}
|
||||||
|
if active_counts:
|
||||||
|
parts = [f"{role}={n}" for role, n in sorted(active_counts.items())]
|
||||||
|
lines.append("Active leases by role: " + ", ".join(parts))
|
||||||
|
else:
|
||||||
|
lines.append("Active leases by role: none")
|
||||||
|
lines.append(
|
||||||
|
f"Stale/expired leases: {len(snapshot.stale_or_expired_leases)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Never list blocked items as safe.
|
||||||
|
if snapshot.blocked_items:
|
||||||
|
lines.append("Blocked (NOT safe):")
|
||||||
|
for entry in snapshot.blocked_items[:12]:
|
||||||
|
lines.append(
|
||||||
|
f" - {entry.kind}#{entry.number}: {entry.block_reason}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
" "
|
||||||
|
+ PROMPT_BLOCKED_ITEM.format(
|
||||||
|
kind=entry.kind,
|
||||||
|
number=entry.number,
|
||||||
|
reason=entry.block_reason or "blocked",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("Next safe action by role:")
|
||||||
|
for role in DASHBOARD_ROLES:
|
||||||
|
action = snapshot.next_safe_by_role.get(role)
|
||||||
|
if action is None:
|
||||||
|
continue
|
||||||
|
target = (
|
||||||
|
f"{action.target_kind}#{action.target_number}"
|
||||||
|
if action.target_number is not None
|
||||||
|
else "none"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f" - {role}: status={action.status} target={target} "
|
||||||
|
f"safe={action.status == 'safe'}"
|
||||||
|
)
|
||||||
|
lines.append(f" prompt: {action.prompt}")
|
||||||
|
|
||||||
|
if snapshot.primary_next_safe_action:
|
||||||
|
p = snapshot.primary_next_safe_action
|
||||||
|
lines.append(
|
||||||
|
f"Primary next: role={p.role} status={p.status} "
|
||||||
|
f"target={p.target_kind}#{p.target_number if p.target_number else 'none'}"
|
||||||
|
)
|
||||||
|
lines.append(f" prompt: {p.prompt}")
|
||||||
|
|
||||||
|
if snapshot.reasons:
|
||||||
|
lines.append("Notes:")
|
||||||
|
for r in snapshot.reasons:
|
||||||
|
lines.append(f" - {r}")
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
"Assignment still requires gitea_allocate_next_work; "
|
||||||
|
"this dashboard never self-selects exclusive work."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
+37
-7
@@ -278,12 +278,19 @@ def assess_root_source_mutation(
|
|||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
locked_issue_number: int | None = None,
|
locked_issue_number: int | None = None,
|
||||||
role_kind: str | None = None,
|
role_kind: str | None = None,
|
||||||
|
mutation_task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
||||||
|
|
||||||
Allowed only when the active workspace is under ``branches/``. Dirty
|
Allowed only when the active workspace is under ``branches/``. Dirty
|
||||||
tracked source/test files on the control checkout always block, including
|
tracked source/test files on the control checkout always block, including
|
||||||
temporary/diagnostic/test-only intent.
|
temporary/diagnostic/test-only intent.
|
||||||
|
|
||||||
|
#749: ``create_issue`` is a pure remote mutation with no local tree write.
|
||||||
|
When *mutation_task* is create_issue and the control checkout has no dirty
|
||||||
|
source/test files, the missing-worktree signal is suppressed so the
|
||||||
|
sanctioned bootstrap path can proceed. Dirty roots and every other task
|
||||||
|
still fail closed.
|
||||||
"""
|
"""
|
||||||
role = (role_kind or "").strip().lower()
|
role = (role_kind or "").strip().lower()
|
||||||
if role == "reconciler":
|
if role == "reconciler":
|
||||||
@@ -302,6 +309,7 @@ def assess_root_source_mutation(
|
|||||||
dirty_src = dirty_source_files(porcelain_status)
|
dirty_src = dirty_source_files(porcelain_status)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
blocker_kind: str | None = None
|
blocker_kind: str | None = None
|
||||||
|
create_issue_bootstrap = False
|
||||||
|
|
||||||
if not under_branches and workspace == root and dirty_src:
|
if not under_branches and workspace == root and dirty_src:
|
||||||
# Root workspace with source dirtiness is unattributed root WIP.
|
# Root workspace with source dirtiness is unattributed root WIP.
|
||||||
@@ -313,32 +321,50 @@ def assess_root_source_mutation(
|
|||||||
)
|
)
|
||||||
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
|
||||||
|
# Lazy import keeps workflow_scope_guard free of circular import at module load.
|
||||||
|
try:
|
||||||
|
import create_issue_bootstrap as _cib
|
||||||
|
except Exception: # pragma: no cover - import always available in-tree
|
||||||
|
_cib = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not under_branches
|
not under_branches
|
||||||
and workspace == root
|
and workspace == root
|
||||||
and not dirty_src
|
and not dirty_src
|
||||||
and role == "author"
|
and role == "author"
|
||||||
):
|
):
|
||||||
# Explicit missing-worktree signal for force-on author entrypoints.
|
if _cib is not None and _cib.is_create_issue_task(mutation_task):
|
||||||
reasons.append(
|
# #749: clean-root create_issue is the sanctioned bootstrap path.
|
||||||
"author source/test mutation from the stable control checkout is "
|
create_issue_bootstrap = True
|
||||||
"forbidden; bind an issue-backed worktree under branches/ first"
|
else:
|
||||||
)
|
# Explicit missing-worktree signal for force-on author entrypoints.
|
||||||
blocker_kind = BLOCKER_MISSING_WORKTREE
|
reasons.append(
|
||||||
|
"author source/test mutation from the stable control checkout is "
|
||||||
|
"forbidden; bind an issue-backed worktree under branches/ first"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_MISSING_WORKTREE
|
||||||
|
|
||||||
if reasons:
|
if reasons:
|
||||||
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
next_action = _NEXT_ACTIONS[kind]
|
||||||
|
if (
|
||||||
|
kind == BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
and _cib is not None
|
||||||
|
and _cib.is_create_issue_task(mutation_task)
|
||||||
|
):
|
||||||
|
next_action = _cib.EXACT_NEXT_ACTION_BOOTSTRAP
|
||||||
return {
|
return {
|
||||||
"proven": False,
|
"proven": False,
|
||||||
"block": True,
|
"block": True,
|
||||||
"blocker_kind": kind,
|
"blocker_kind": kind,
|
||||||
"exact_next_action": _NEXT_ACTIONS[kind],
|
"exact_next_action": next_action,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"dirty_source_files": dirty_src,
|
"dirty_source_files": dirty_src,
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"canonical_repo_root": root,
|
"canonical_repo_root": root,
|
||||||
"under_branches": under_branches,
|
"under_branches": under_branches,
|
||||||
"locked_issue_number": locked_issue_number,
|
"locked_issue_number": locked_issue_number,
|
||||||
|
"create_issue_bootstrap": False,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"proven": True,
|
"proven": True,
|
||||||
@@ -351,6 +377,7 @@ def assess_root_source_mutation(
|
|||||||
"canonical_repo_root": root,
|
"canonical_repo_root": root,
|
||||||
"under_branches": under_branches,
|
"under_branches": under_branches,
|
||||||
"locked_issue_number": locked_issue_number,
|
"locked_issue_number": locked_issue_number,
|
||||||
|
"create_issue_bootstrap": create_issue_bootstrap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +392,7 @@ def assess_production_mutation_guards(
|
|||||||
role_kind: str | None = None,
|
role_kind: str | None = None,
|
||||||
require_author_lock: bool = False,
|
require_author_lock: bool = False,
|
||||||
in_test_mode: bool = False,
|
in_test_mode: bool = False,
|
||||||
|
mutation_task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Compose root + scope production guards when they must be active (#683)."""
|
"""Compose root + scope production guards when they must be active (#683)."""
|
||||||
if not production_guards_active(in_test_mode=in_test_mode):
|
if not production_guards_active(in_test_mode=in_test_mode):
|
||||||
@@ -385,6 +413,7 @@ def assess_production_mutation_guards(
|
|||||||
current_branch=current_branch,
|
current_branch=current_branch,
|
||||||
locked_issue_number=locked_issue_number,
|
locked_issue_number=locked_issue_number,
|
||||||
role_kind=role_kind,
|
role_kind=role_kind,
|
||||||
|
mutation_task=mutation_task,
|
||||||
)
|
)
|
||||||
if root_assess["block"]:
|
if root_assess["block"]:
|
||||||
return {**root_assess, "skipped": False}
|
return {**root_assess, "skipped": False}
|
||||||
@@ -408,6 +437,7 @@ def assess_production_mutation_guards(
|
|||||||
"skipped": False,
|
"skipped": False,
|
||||||
"root": root_assess,
|
"root": root_assess,
|
||||||
"scope": scope_assess,
|
"scope": scope_assess,
|
||||||
|
"create_issue_bootstrap": bool(root_assess.get("create_issue_bootstrap")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user