Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5463f58933 | ||
|
|
5032965e3a | ||
|
|
57a52b1a99 | ||
|
|
344dc41ce2 | ||
|
|
620ed6e9a9 | ||
|
|
a30a3ce4c3 | ||
|
|
1a97ced133 | ||
|
|
3d0c13fa5a | ||
|
|
0f19773076 | ||
|
|
aa4fe1cc7b | ||
|
|
6b58f04d39 | ||
|
|
35e94e107c | ||
|
|
1ec4672fad | ||
|
|
7ecf7bf2d6 | ||
|
|
0589ec8069 | ||
|
|
300e8acd13 | ||
|
|
a002864a06 | ||
|
|
8e149e6cfa | ||
|
|
ed0e8c82de | ||
|
|
df3167488c | ||
|
|
ddc9b97d40 | ||
|
|
1d11cbab0f | ||
|
|
d17f055e86 | ||
|
|
52ded0ea71 | ||
|
|
296601647d | ||
|
|
702ceb2480 | ||
|
|
6c15aa88b3 | ||
|
|
c31df2130c | ||
|
|
ccfaa0ec0c | ||
|
|
ca76dacd73 | ||
|
|
ad13d872df | ||
|
|
0c2f45abb7 | ||
|
|
5ed2ab8a38 | ||
|
|
0568f44cb2 | ||
|
|
ab34280f90 | ||
|
|
9bf3acfef6 | ||
|
|
059ee77c1f | ||
|
|
bc968dd2e0 | ||
|
|
716fc21a0d | ||
|
|
edaeede250 | ||
|
|
5547399037 | ||
|
|
cb6ae0ca50 | ||
|
|
d12adabeb1 | ||
|
|
4b8a9219d8 | ||
|
|
e168978579 | ||
|
|
fcf6981b1b | ||
|
|
d181d499d3 | ||
|
|
7f2b9f36de | ||
|
|
324b4b3e93 |
+476
-10
@@ -18,10 +18,12 @@ after they exist as normal issues; this module never assigns incidents.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Sequence
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
@@ -40,6 +42,22 @@ OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||
OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
# #765: ownership could not be established for every remaining candidate.
|
||||
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
|
||||
# #776: excluded issue still carries a live same-owner lease — resume or release.
|
||||
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE = "blocked_by_excluded_own_lease"
|
||||
# #776: dry-run/apply candidate-set fingerprint mismatch (CAS drift).
|
||||
OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||
|
||||
# #765 skip reason code for work already claimed by a different controller.
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||
# #776: controller-supplied pre-rank exclusion.
|
||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||
|
||||
# Ownership verdicts for a live claim on a candidate (#765).
|
||||
OWNERSHIP_OWN = "own"
|
||||
OWNERSHIP_FOREIGN = "foreign"
|
||||
OWNERSHIP_UNKNOWN = "unknown"
|
||||
|
||||
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
|
||||
# alongside allocator results so the flat status:ready tier and its
|
||||
@@ -144,9 +162,76 @@ class SkipRecord:
|
||||
kind: str
|
||||
number: int
|
||||
reason: str
|
||||
reason_code: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"number": self.number,
|
||||
"reason": self.reason,
|
||||
"reason_code": self.reason_code,
|
||||
}
|
||||
|
||||
|
||||
CONTROLLER_INSTANCE_ENV = "GITEA_CONTROLLER_INSTANCE_ID"
|
||||
|
||||
|
||||
def resolve_controller_instance_id(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Return this controller's stable identity, or ``None`` if undeclared.
|
||||
|
||||
Deliberately has no derived fallback. The obvious candidates are unsafe:
|
||||
``session_id`` is regenerated per invocation, and the MCP process pid is
|
||||
shared by every controller attached to the same daemon — two independent
|
||||
controllers really do report the same pid and profile. Guessing from either
|
||||
would let one controller adopt another's lease, which is the failure #765
|
||||
exists to prevent. When this returns ``None``, live claims are treated as
|
||||
unidentified: they are excluded from selection and reported as ownership
|
||||
defects rather than adopted.
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(CONTROLLER_INSTANCE_ENV) or "").strip() or None
|
||||
|
||||
|
||||
def classify_claim_ownership(
|
||||
claim: dict[str, Any] | None,
|
||||
*,
|
||||
session_id: str | None,
|
||||
controller_instance_id: str | None,
|
||||
) -> str | None:
|
||||
"""Classify a live claim as own / foreign / unknown ownership (#765).
|
||||
|
||||
Returns ``None`` when the candidate carries no live claim.
|
||||
|
||||
Session ids are regenerated per allocator invocation, so they only prove
|
||||
ownership positively (an exact match is certainly this session). The
|
||||
durable signal is ``controller_instance_id``. When either side lacks one,
|
||||
ownership is *unknown*: the allocator must not assume that a lease sharing
|
||||
the same profile belongs to this controller, so unknown is treated as
|
||||
not-ours for selection purposes and reported as an ownership defect.
|
||||
"""
|
||||
if not claim:
|
||||
return None
|
||||
claim_session = str(claim.get("session_id") or "").strip()
|
||||
claim_instance = str(claim.get("controller_instance_id") or "").strip()
|
||||
own_session = str(session_id or "").strip()
|
||||
own_instance = str(controller_instance_id or "").strip()
|
||||
|
||||
if claim_session and own_session and claim_session == own_session:
|
||||
return OWNERSHIP_OWN
|
||||
if claim_instance and own_instance:
|
||||
return (
|
||||
OWNERSHIP_OWN if claim_instance == own_instance else OWNERSHIP_FOREIGN
|
||||
)
|
||||
if not claim_instance and not own_instance:
|
||||
# Neither side declares a controller identity. The session ids differ
|
||||
# (an exact match returned OWN above), so this is simply someone
|
||||
# else's lease: foreign, and we wait rather than adopt.
|
||||
return OWNERSHIP_FOREIGN
|
||||
# Exactly one side is identified, so the two cannot be compared: this may
|
||||
# or may not be our own task under a different session id. Never guess.
|
||||
return OWNERSHIP_UNKNOWN
|
||||
|
||||
|
||||
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
@@ -203,8 +288,16 @@ def classify_skip(
|
||||
*,
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
claim_ownership: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||
"""Return skip reason, or None if candidate is selectable for *role*.
|
||||
|
||||
*claim_ownership* (#765) is the verdict from
|
||||
:func:`classify_claim_ownership` for this candidate's live claim. Foreign
|
||||
and unknown claims are excluded so one session's in-progress task can never
|
||||
blockade the queue for a different controller; ``own`` stays selectable so
|
||||
a controller can resume its own work.
|
||||
"""
|
||||
if c.state in ("merged", "closed"):
|
||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||
if c.blocked or "status:blocked" in c.labels:
|
||||
@@ -214,6 +307,16 @@ def classify_skip(
|
||||
c.dependency_reason
|
||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||
)
|
||||
if claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN):
|
||||
detail = (
|
||||
"owned by another controller instance"
|
||||
if claim_ownership == OWNERSHIP_FOREIGN
|
||||
else "owner could not be identified; never adopt on a guess"
|
||||
)
|
||||
return (
|
||||
f"{c.kind}#{c.number} {SKIP_CLAIMED_BY_OTHER_SESSION}: "
|
||||
f"active lease {detail}"
|
||||
)
|
||||
if c.already_claimed_elsewhere:
|
||||
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||
@@ -274,6 +377,113 @@ def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||
)
|
||||
|
||||
|
||||
def _require_strict_int(value: Any, *, field: str) -> int:
|
||||
"""Parse an issue number; reject bools and non-integers (#776)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"{field} must be an integer (booleans and non-integers rejected; "
|
||||
f"got {type(value).__name__})"
|
||||
)
|
||||
return int(value)
|
||||
|
||||
|
||||
def normalize_exclude_issue_numbers(
|
||||
exclude_issue_numbers: Any = None,
|
||||
) -> list[int]:
|
||||
"""Normalize controller-supplied exclusions to a sorted unique int list (#776).
|
||||
|
||||
``None`` / omitted → empty list (existing behavior). Accepts a list/tuple of
|
||||
integers. Rejects scalars, bools-as-ints, nested structures, and strings.
|
||||
"""
|
||||
if exclude_issue_numbers is None:
|
||||
return []
|
||||
if isinstance(exclude_issue_numbers, (str, bytes)) or not isinstance(
|
||||
exclude_issue_numbers, (list, tuple)
|
||||
):
|
||||
raise ValueError(
|
||||
"exclude_issue_numbers must be a list of integers "
|
||||
f"(got {type(exclude_issue_numbers).__name__})"
|
||||
)
|
||||
out: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for idx, raw in enumerate(exclude_issue_numbers):
|
||||
num = _require_strict_int(raw, field=f"exclude_issue_numbers[{idx}]")
|
||||
if num not in seen:
|
||||
seen.add(num)
|
||||
out.append(num)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def candidate_set_fingerprint(
|
||||
candidates: Sequence[WorkCandidate],
|
||||
*,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
) -> str:
|
||||
"""Stable CAS fingerprint of normalized candidate set + exclusions (#776 AC4)."""
|
||||
payload = {
|
||||
"candidates": sorted(
|
||||
({"kind": c.kind, "number": int(c.number)} for c in candidates),
|
||||
key=lambda x: (x["kind"], x["number"]),
|
||||
),
|
||||
"exclude_issue_numbers": list(
|
||||
normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
),
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_candidates_payload(raw: Any) -> list[WorkCandidate]:
|
||||
"""Decode MCP ``candidates_json`` from list or JSON string (#776 AC3).
|
||||
|
||||
Accepts:
|
||||
* an already-decoded ``list`` of candidate dicts (native MCP transport);
|
||||
* a valid JSON string that decodes to such a list (backward compatible).
|
||||
|
||||
Rejects malformed JSON, scalars, non-list containers, invalid records,
|
||||
booleans-as-integers, and unsupported types with fail-closed ``ValueError``.
|
||||
"""
|
||||
if raw is None:
|
||||
raise ValueError("candidates_json is empty")
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
f"candidates_json bytes are not valid utf-8: {exc}"
|
||||
) from exc
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise ValueError("candidates_json string is empty")
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"malformed candidates_json JSON: {exc.msg} at pos {exc.pos}"
|
||||
) from exc
|
||||
raw = decoded
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(
|
||||
"candidates_json must be a JSON list (or already-decoded list); "
|
||||
f"got {type(raw).__name__}"
|
||||
)
|
||||
candidates: list[WorkCandidate] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: expected object, "
|
||||
f"got {type(item).__name__}"
|
||||
)
|
||||
try:
|
||||
candidates.append(candidate_from_dict(item))
|
||||
except (KeyError, TypeError, ValueError, InvalidWorkKindError) as exc:
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: {exc}"
|
||||
) from exc
|
||||
return candidates
|
||||
|
||||
|
||||
def allocate_next_work(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
@@ -287,12 +497,22 @@ def allocate_next_work(
|
||||
profile_name: str | None = None,
|
||||
username: str | None = None,
|
||||
lease_ttl_seconds: int | None = None,
|
||||
controller_instance_id: str | None = None,
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
expected_candidate_set_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||
|
||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||
|
||||
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||
empty preserves prior behavior.
|
||||
|
||||
*expected_candidate_set_fingerprint* (#776 AC4): when set on apply, rejects
|
||||
material candidate-set drift vs a prior dry-run.
|
||||
|
||||
Never uses file locks or comment-only leases as the assignment source.
|
||||
"""
|
||||
if db is None:
|
||||
@@ -328,6 +548,7 @@ def allocate_next_work(
|
||||
role=role_norm,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface structured
|
||||
return {
|
||||
@@ -369,30 +590,238 @@ def allocate_next_work(
|
||||
}
|
||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||
|
||||
# #765: live claims exclude work owned by a *different* controller before
|
||||
# ranking, so one session's in-progress task cannot blockade the queue.
|
||||
# #776 AC5: load live claims in this call path immediately before selection
|
||||
# (and before apply reserve) so ownership is never stale within the
|
||||
# allocation attempt. Test callers may inject *claims* explicitly.
|
||||
if claims is None:
|
||||
try:
|
||||
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
f"active claim lookup failed: {exc} (fail closed, #765)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
try:
|
||||
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
f"invalid exclude_issue_numbers: {exc} (fail closed, #776)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
exclude_set = set(exclude_nums)
|
||||
cas_fp = candidate_set_fingerprint(
|
||||
candidates, exclude_issue_numbers=exclude_nums
|
||||
)
|
||||
expected_fp = (expected_candidate_set_fingerprint or "").strip() or None
|
||||
if expected_fp and expected_fp != cas_fp:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_CANDIDATE_SET_DRIFT,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
"candidate-set fingerprint drift: apply rejected rather than "
|
||||
"silently leasing a different candidate (#776 AC4)"
|
||||
],
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"expected_candidate_set_fingerprint": expected_fp,
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
skipped: list[SkipRecord] = []
|
||||
ordered = sort_candidates(list(candidates))
|
||||
claims_excluded: list[dict[str, Any]] = []
|
||||
ownership_defects: list[dict[str, Any]] = []
|
||||
controller_excluded: list[dict[str, Any]] = []
|
||||
|
||||
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||
rankable: list[WorkCandidate] = []
|
||||
for c in candidates:
|
||||
if int(c.number) in exclude_set:
|
||||
reason = (
|
||||
f"{c.kind}#{c.number} {SKIP_EXCLUDED_BY_CONTROLLER}: "
|
||||
"controller pre-rank exclusion"
|
||||
)
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
)
|
||||
)
|
||||
controller_excluded.append(
|
||||
{
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"reason_code": SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
}
|
||||
)
|
||||
# #776 AC5: same-owner live lease on an excluded issue is a
|
||||
# structured resume/release blocker, never a silent strand.
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
if ownership == OWNERSHIP_OWN and claim:
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
"apply": bool(apply),
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": None,
|
||||
"expected_role_next": None,
|
||||
"reasons": [
|
||||
f"{c.kind}#{c.number} is excluded_by_controller but "
|
||||
"carries a live same-owner lease; resume or release "
|
||||
"that lease before allocating other work (#776 AC5)"
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"blocked_lease": {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
"safe_next_action": (
|
||||
"resume the same-owner lease or release it, then "
|
||||
"re-run allocation without stranding the excluded "
|
||||
"issue"
|
||||
),
|
||||
},
|
||||
}
|
||||
continue
|
||||
rankable.append(c)
|
||||
|
||||
ordered = sort_candidates(rankable)
|
||||
selected: WorkCandidate | None = None
|
||||
for c in ordered:
|
||||
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
reason = classify_skip(
|
||||
c,
|
||||
role=role_norm,
|
||||
terminal_pr=terminal_pr,
|
||||
claim_ownership=ownership,
|
||||
)
|
||||
if reason:
|
||||
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION if is_claim_skip else None,
|
||||
)
|
||||
)
|
||||
if is_claim_skip and claim:
|
||||
record = {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"ownership": ownership,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
}
|
||||
claims_excluded.append(record)
|
||||
if ownership == OWNERSHIP_UNKNOWN:
|
||||
ownership_defects.append(record)
|
||||
continue
|
||||
selected = c
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# If terminal lock blocks all review work, surface that explicitly.
|
||||
owner_session_id: str | None = None
|
||||
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||
reasons = [
|
||||
f"no safe work for role '{role_norm}': active terminal-review "
|
||||
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
||||
]
|
||||
elif ownership_defects:
|
||||
# #765: every remaining candidate is claimed and at least one owner
|
||||
# could not be identified. Report the defect; never adopt.
|
||||
outcome = OUTCOME_OWNERSHIP_DEFECT
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}': "
|
||||
f"{len(ownership_defects)} candidate(s) carry an active lease "
|
||||
"whose controller ownership could not be established. Record a "
|
||||
"controller_instance_id on those sessions; the allocator will "
|
||||
"not assume a shared profile means shared ownership (#765)."
|
||||
]
|
||||
elif claims_excluded:
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no unclaimed work for role '{role_norm}': "
|
||||
f"{len(claims_excluded)} candidate(s) are actively claimed by "
|
||||
"another controller. Waiting; their leases are not adopted (#765)."
|
||||
]
|
||||
# Preserve the pre-#765 wait contract: name the blocking owner.
|
||||
owner_session_id = claims_excluded[0].get("owner_session_id")
|
||||
elif controller_excluded and not ordered:
|
||||
# #776 AC7: every candidate was controller-excluded → wait / no lease.
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no assignable work for role '{role_norm}': all "
|
||||
f"{len(controller_excluded)} candidate(s) were removed by "
|
||||
f"{SKIP_EXCLUDED_BY_CONTROLLER} before ranking; no assignment "
|
||||
"or lease created (#776 AC7)"
|
||||
]
|
||||
else:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}' "
|
||||
f"among {len(ordered)} candidates"
|
||||
f"among {len(ordered)} rankable candidates "
|
||||
f"({len(controller_excluded)} controller-excluded)"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
@@ -414,6 +843,13 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"owner_session_id": owner_session_id,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -460,6 +896,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -583,6 +1025,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -614,14 +1062,32 @@ def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
|
||||
|
||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory).
|
||||
|
||||
#776: reject booleans-as-integers and non-int numbers fail-closed.
|
||||
"""
|
||||
if "number" not in data:
|
||||
raise KeyError("number")
|
||||
number = _require_strict_int(data["number"], field="number")
|
||||
priority_raw = data.get("priority") or 0
|
||||
if isinstance(priority_raw, bool) or not isinstance(priority_raw, (int, float)):
|
||||
# Allow numeric strings only for priority? Keep strict for bools.
|
||||
if isinstance(priority_raw, str) and priority_raw.strip().lstrip("-").isdigit():
|
||||
priority = int(priority_raw)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"priority must be numeric (booleans rejected; "
|
||||
f"got {type(priority_raw).__name__})"
|
||||
)
|
||||
else:
|
||||
priority = int(priority_raw)
|
||||
return WorkCandidate(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
number=number,
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
priority=int(data.get("priority") or 0),
|
||||
priority=priority,
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||
|
||||
@@ -77,6 +77,7 @@ MUTATION_TASKS = frozenset({
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"close_issue",
|
||||
"edit_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
|
||||
+544
-3
@@ -1,7 +1,15 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
project's ``branches/`` directory, never from the stable control checkout.
|
||||
|
||||
#618 durable resolution:
|
||||
- Prefer an explicit validated ``worktree_path`` argument.
|
||||
- Else derive the workspace from the active author issue lock's worktree.
|
||||
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
|
||||
when present and valid.
|
||||
- Author mutations never silently fall back to the control checkout or master.
|
||||
- Missing configured bindings fail closed with a clear operator recovery action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +23,18 @@ AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
|
||||
BOUND_WORKTREE_MISSING_MESSAGE = (
|
||||
"bound worktree missing; operator must recreate or repoint the worktree "
|
||||
"and reconnect"
|
||||
)
|
||||
OPERATOR_RECOVERY_RECREATE_REPOINT = (
|
||||
"Recreate the worktree under branches/ (scripts/worktree-start or "
|
||||
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
|
||||
"to that path (or pass worktree_path on mutation tools), keep the control "
|
||||
"checkout clean on master, then reconnect the author MCP session and re-run."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
"""Resolve the workspace path inspected before author mutations.
|
||||
|
||||
Legacy helper: returns the first non-empty candidate path. Prefer
|
||||
:func:`resolve_durable_author_worktree` for mutation guards (#618).
|
||||
"""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #618 durable author worktree resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _abs_real(path: str) -> str:
|
||||
return os.path.realpath(os.path.abspath((path or "").strip()))
|
||||
|
||||
|
||||
def assess_path_traversal_safety(
|
||||
*,
|
||||
path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed on traversal/symlink escapes outside the target repository.
|
||||
|
||||
Uses ``realpath`` so intermediate symlinks cannot walk outside
|
||||
``canonical_repo_root``. Author mutation workspaces must also land under
|
||||
``branches/`` of that root (enforced separately by the branches-only guard).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
raw = (path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path is empty (fail closed)"],
|
||||
"workspace_path": None,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
if "\x00" in raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path contains a null byte (fail closed)"],
|
||||
"workspace_path": raw,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
# Resolve without requiring existence first: abspath then realpath of parents.
|
||||
abs_path = os.path.abspath(raw)
|
||||
try:
|
||||
real = os.path.realpath(abs_path)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [f"worktree path could not be resolved safely: {exc}"],
|
||||
"workspace_path": abs_path,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
root_norm = _normalize_path(root)
|
||||
real_norm = _normalize_path(real)
|
||||
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
|
||||
reasons.append(
|
||||
f"worktree path '{real}' escapes canonical repository root '{root}' "
|
||||
"(traversal/symlink safety, fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"workspace_path": real,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
|
||||
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
|
||||
"""Return realpaths registered in ``git worktree list --porcelain``."""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
paths.append(os.path.realpath(raw))
|
||||
return paths
|
||||
|
||||
|
||||
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
|
||||
"""True/False when inventory is available; None when git inventory fails.
|
||||
|
||||
An empty inventory with a working git root is treated as inconclusive
|
||||
(``None``) so unit tests and partial sandboxes are not false-negative
|
||||
blocked when ``git worktree list`` is mocked/unavailable.
|
||||
"""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
inventory: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
inventory.append(os.path.realpath(raw))
|
||||
if not inventory:
|
||||
return None
|
||||
return os.path.realpath(path) in inventory
|
||||
|
||||
|
||||
def assess_bound_worktree_existence(
|
||||
*,
|
||||
configured_path: str,
|
||||
binding_source: str,
|
||||
canonical_repo_root: str | None = None,
|
||||
role_kind: str = "author",
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
|
||||
raw = (configured_path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": None,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"configured_path": None,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
try:
|
||||
real = _abs_real(raw)
|
||||
except OSError:
|
||||
real = os.path.abspath(raw)
|
||||
|
||||
path_exists = os.path.isdir(real)
|
||||
in_list: bool | None = None
|
||||
inspected_git_root: str | None = None
|
||||
root = (canonical_repo_root or "").strip()
|
||||
if root:
|
||||
in_list = path_in_git_worktree_list(real, root) if path_exists else False
|
||||
if path_exists:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", real, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
|
||||
if path_exists:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": [],
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
reasons = [
|
||||
BOUND_WORKTREE_MISSING_MESSAGE,
|
||||
(
|
||||
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
|
||||
f"points to '{real}' which does not exist on disk"
|
||||
),
|
||||
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
|
||||
]
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": False if in_list is not None else False,
|
||||
"inspected_git_root": None,
|
||||
"reasons": reasons,
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
|
||||
|
||||
def format_bound_worktree_missing_error(assessment: dict) -> str:
|
||||
"""Canonical operator-facing message for a missing author worktree binding."""
|
||||
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
|
||||
source = (
|
||||
assessment.get("binding_source")
|
||||
or assessment.get("workspace_binding_source")
|
||||
or "unknown binding"
|
||||
)
|
||||
path = (
|
||||
assessment.get("configured_path")
|
||||
or assessment.get("workspace_path")
|
||||
or "(unknown)"
|
||||
)
|
||||
return (
|
||||
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
|
||||
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
|
||||
def assess_lock_worktree_ownership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
session_lock_worktree: str | None,
|
||||
) -> dict:
|
||||
"""When a live lock records a worktree, mutation workspace must match it."""
|
||||
locked = (session_lock_worktree or "").strip()
|
||||
if not locked:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
|
||||
"lock_worktree_path": None,
|
||||
}
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
locked_real = os.path.realpath(locked)
|
||||
if workspace != locked_real:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"active author issue lock worktree '{locked_real}' does not match "
|
||||
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
|
||||
],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
|
||||
|
||||
def resolve_durable_author_worktree(
|
||||
*,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
canonical_repo_root: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
validate: bool = True,
|
||||
) -> dict:
|
||||
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
|
||||
|
||||
Candidate priority:
|
||||
1. explicit ``worktree_path`` argument
|
||||
2. ``worktree`` argument
|
||||
3. ``GITEA_ACTIVE_WORKTREE``
|
||||
4. ``GITEA_AUTHOR_WORKTREE``
|
||||
5. active author issue lock ``worktree_path``
|
||||
6. process project root **only** when it is already under ``branches/``
|
||||
|
||||
Configured bindings that point at a missing path fail closed immediately
|
||||
(no demotion to the control checkout). Validation (when *validate*) covers
|
||||
existence, traversal/symlink safety, repository identity, branches/
|
||||
containment, and lock ownership.
|
||||
"""
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
canonical = os.path.realpath(canonical_repo_root or process_root)
|
||||
reasons: list[str] = []
|
||||
role = "author"
|
||||
|
||||
candidates: list[tuple[str | None, str, bool]] = [
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
|
||||
(session_lock_worktree, "active author issue lock worktree", False),
|
||||
]
|
||||
|
||||
selected_path: str | None = None
|
||||
selected_source: str | None = None
|
||||
existence: dict | None = None
|
||||
|
||||
for candidate, source, _configured in candidates:
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
real = _abs_real(text)
|
||||
except OSError:
|
||||
real = os.path.abspath(text)
|
||||
|
||||
existence = assess_bound_worktree_existence(
|
||||
configured_path=real,
|
||||
binding_source=source,
|
||||
canonical_repo_root=canonical,
|
||||
role_kind=role,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
if existence["block"]:
|
||||
# Missing configured binding: fail closed, never fall back (#618).
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": real,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": existence.get("in_git_worktree_list"),
|
||||
"inspected_git_root": None,
|
||||
"reasons": list(existence.get("reasons") or []),
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
selected_path = real
|
||||
selected_source = source
|
||||
break
|
||||
|
||||
if selected_path is None:
|
||||
# No explicit/env/lock binding. Allow process root only when it is a
|
||||
# branches/ worktree (MCP launched from the task worktree). Never
|
||||
# silently bind the stable control checkout.
|
||||
if is_path_under_branches(process_root, canonical):
|
||||
selected_path = process_root
|
||||
selected_source = "MCP process root under branches/ (session-owned)"
|
||||
else:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": process_root,
|
||||
"workspace_binding_source": "no author worktree binding",
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": os.path.isdir(process_root),
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"author mutation requires an explicit validated worktree_path "
|
||||
"or a worktree derived from the active author issue lock; "
|
||||
"silent fallback to the control checkout/master is forbidden (#618)"
|
||||
],
|
||||
"blocker_kind": "author_worktree_unbound_control_checkout",
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
workspace = selected_path
|
||||
source = selected_source or "unknown"
|
||||
path_exists = os.path.isdir(workspace)
|
||||
inspected_git_root: str | None = None
|
||||
in_list: bool | None = None
|
||||
|
||||
if not validate:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
# Traversal / symlink safety
|
||||
safety = assess_path_traversal_safety(
|
||||
path=workspace, canonical_repo_root=canonical
|
||||
)
|
||||
if safety["block"]:
|
||||
reasons.extend(safety["reasons"])
|
||||
else:
|
||||
workspace = safety["workspace_path"] or workspace
|
||||
|
||||
# Existence + git inventory
|
||||
if not path_exists:
|
||||
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
reasons.append(f"resolved worktree '{workspace}' does not exist")
|
||||
else:
|
||||
in_list = path_in_git_worktree_list(workspace, canonical)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
if in_list is False:
|
||||
# Only hard-fail when inventory was obtained and the path is absent.
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' is not listed in git worktree list for "
|
||||
f"'{canonical}' (fail closed)"
|
||||
)
|
||||
|
||||
# Repository identity
|
||||
if path_exists:
|
||||
membership = assess_workspace_repo_membership(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=canonical,
|
||||
)
|
||||
if membership["block"]:
|
||||
reasons.extend(membership["reasons"])
|
||||
|
||||
# branches/ containment
|
||||
branches = assess_author_mutation_worktree(
|
||||
workspace_path=workspace,
|
||||
project_root=canonical,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
|
||||
# Lock ownership (when a lock worktree is recorded)
|
||||
lock_own = assess_lock_worktree_ownership(
|
||||
workspace_path=workspace,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
)
|
||||
if lock_own["block"]:
|
||||
reasons.extend(lock_own["reasons"])
|
||||
|
||||
# Forbid resolved control checkout even if somehow selected
|
||||
if workspace == canonical or workspace == process_root:
|
||||
if not is_path_under_branches(workspace, canonical):
|
||||
if not any("control checkout" in r for r in reasons):
|
||||
reasons.append(
|
||||
"author mutation blocked: resolved workspace is the stable "
|
||||
"control checkout; silent fallback forbidden (#618)"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": bound_missing,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": reasons,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
|
||||
"author_worktree_validation_failed" if block else None
|
||||
),
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
|
||||
def format_durable_author_worktree_error(assessment: dict) -> str:
|
||||
"""Format fail-closed error for durable author worktree resolution."""
|
||||
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
|
||||
return format_bound_worktree_missing_error(assessment)
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
source = assessment.get("workspace_binding_source") or "unknown"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["author worktree resolution failed"]
|
||||
)
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
return (
|
||||
f"Durable author worktree resolution blocked (#618): {reasons}. "
|
||||
f"workspace: {workspace}; binding_source: {source}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
+438
-14
@@ -29,7 +29,9 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
import dependency_graph
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
# Assignable work kinds only — raw monitoring incidents are never work items.
|
||||
WORK_KINDS = frozenset({"issue", "pr"})
|
||||
@@ -147,7 +149,41 @@ CREATE TABLE IF NOT EXISTS incident_links (
|
||||
UNIQUE (provider, provider_base_url, provider_org, provider_project, provider_issue_id)
|
||||
);
|
||||
|
||||
-- Durable dependency graph (#784, umbrella #628 scope item 6). Dependencies
|
||||
-- were previously re-parsed per allocation run and discarded; each row here is
|
||||
-- one relationship with its conditions, current state, and evidence. Creating
|
||||
-- the table is itself the v3→v4 migration: additive, idempotent, and it never
|
||||
-- touches the pre-existing tables.
|
||||
CREATE TABLE IF NOT EXISTS dependency_edges (
|
||||
edge_id TEXT PRIMARY KEY,
|
||||
remote TEXT NOT NULL,
|
||||
org TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('issue', 'pr')),
|
||||
source_number INTEGER NOT NULL,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('issue', 'pr')),
|
||||
target_number INTEGER NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
blocking_condition TEXT NOT NULL DEFAULT '',
|
||||
completion_condition TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_observed_at TEXT NOT NULL,
|
||||
UNIQUE (
|
||||
remote, org, repo, source_kind, source_number,
|
||||
target_kind, target_number, edge_type
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_work_status ON leases(work_item_id, status);
|
||||
-- Reverse lookup ("what waits on this target") is the query automatic
|
||||
-- resumption needs, so it gets its own index alongside the forward one.
|
||||
CREATE INDEX IF NOT EXISTS idx_dependency_edges_source
|
||||
ON dependency_edges(remote, org, repo, source_kind, source_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependency_edges_target
|
||||
ON dependency_edges(remote, org, repo, target_kind, target_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_assignments_session ON assignments(session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_gitea ON incident_links(gitea_org, gitea_repo, gitea_issue_number);
|
||||
"""
|
||||
@@ -302,6 +338,7 @@ class ControlPlaneDB:
|
||||
conn.executescript(_SCHEMA_SQL)
|
||||
self._migrate_incident_links_null_scope(conn)
|
||||
self._migrate_lease_lifecycle_columns(conn)
|
||||
self._migrate_session_ownership_columns(conn)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
@@ -492,32 +529,62 @@ class ControlPlaneDB:
|
||||
namespace: str | None = None,
|
||||
pid: int | None = None,
|
||||
status: str = "active",
|
||||
controller_instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Register/refresh a session row.
|
||||
|
||||
*controller_instance_id* (#765) is the stable identity of the
|
||||
controller that owns this session. Session ids are regenerated per
|
||||
invocation, so they cannot express "my own in-progress task"; the
|
||||
controller instance can. It is never overwritten with ``None``, so a
|
||||
heartbeat from a caller that does not supply one cannot erase
|
||||
ownership.
|
||||
"""
|
||||
now = _ts()
|
||||
instance = (controller_instance_id or "").strip() or None
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
if instance is None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?,
|
||||
controller_instance_id = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(
|
||||
role, profile, namespace, pid, now, status,
|
||||
instance, session_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions(
|
||||
session_id, role, profile, namespace, pid,
|
||||
started_at, last_heartbeat_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
started_at, last_heartbeat_at, status,
|
||||
controller_instance_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(session_id, role, profile, namespace, pid, now, now, status),
|
||||
(
|
||||
session_id, role, profile, namespace, pid, now, now,
|
||||
status, instance,
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
@@ -1215,6 +1282,73 @@ class ControlPlaneDB:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}")
|
||||
|
||||
_SESSION_OWNERSHIP_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("controller_instance_id", "TEXT"),
|
||||
)
|
||||
|
||||
def _migrate_session_ownership_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""Add the stable controller identity to sessions (#765).
|
||||
|
||||
Pre-existing rows migrate with ``NULL``. A NULL instance is treated as
|
||||
*unknown ownership* by the allocator and is never silently adopted.
|
||||
"""
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
|
||||
}
|
||||
if not cols:
|
||||
return
|
||||
for name, decl in self._SESSION_OWNERSHIP_COLUMNS:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
|
||||
|
||||
def list_active_claims(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
role: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> dict[tuple[str, int], dict[str, Any]]:
|
||||
"""Map ``(work_kind, work_number)`` to its live claim (#765).
|
||||
|
||||
Only ``active`` leases count as claims; released/expired rows never
|
||||
withhold work. Callers compare the returned ``controller_instance_id``
|
||||
against their own to decide own-task vs foreign-task.
|
||||
"""
|
||||
claims: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
for row in self.list_leases(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
role=role,
|
||||
statuses=("active",),
|
||||
limit=limit,
|
||||
):
|
||||
kind = str(row.get("work_kind") or "").strip().lower()
|
||||
number = row.get("work_number")
|
||||
if not kind or number is None:
|
||||
continue
|
||||
key = (kind, int(number))
|
||||
claim = {
|
||||
"lease_id": row.get("lease_id"),
|
||||
"session_id": row.get("session_id"),
|
||||
"controller_instance_id": row.get("session_controller_instance_id"),
|
||||
"role": row.get("role"),
|
||||
"profile": row.get("session_profile"),
|
||||
"expires_at": row.get("expires_at"),
|
||||
"work_kind": kind,
|
||||
"work_number": int(number),
|
||||
}
|
||||
# Keep the longest-lived claim when duplicates exist.
|
||||
previous = claims.get(key)
|
||||
if previous is None or str(claim["expires_at"] or "") > str(
|
||||
previous["expires_at"] or ""
|
||||
):
|
||||
claims[key] = claim
|
||||
return claims
|
||||
|
||||
def _lease_columns(self, conn: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[1]
|
||||
@@ -1256,7 +1390,8 @@ class ControlPlaneDB:
|
||||
w.number AS work_number, w.state AS work_state,
|
||||
w.current_head_sha AS work_head_sha,
|
||||
s.pid AS session_pid, s.profile AS session_profile,
|
||||
s.status AS session_status
|
||||
s.status AS session_status,
|
||||
s.controller_instance_id AS session_controller_instance_id
|
||||
FROM leases l
|
||||
JOIN work_items w ON w.work_item_id = l.work_item_id
|
||||
LEFT JOIN sessions s ON s.session_id = l.session_id
|
||||
@@ -1749,3 +1884,292 @@ class ControlPlaneDB:
|
||||
f"transferred lease ownership from {owner} to {adopter_session_id}"
|
||||
],
|
||||
}
|
||||
|
||||
# --- Dependency graph (#784, umbrella #628 scope item 6) ----------------
|
||||
|
||||
@staticmethod
|
||||
def _dependency_edge_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
"""Return a stored edge as a plain dict with evidence decoded."""
|
||||
if row is None:
|
||||
return None
|
||||
edge = dict(row)
|
||||
raw = edge.get("evidence")
|
||||
try:
|
||||
edge["evidence"] = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
# A row written by an older/foreign writer must not break reads.
|
||||
edge["evidence"] = {"unparsed": str(raw)}
|
||||
return edge
|
||||
|
||||
def upsert_dependency_edge(
|
||||
self,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
source_kind: str,
|
||||
source_number: int,
|
||||
target_kind: str,
|
||||
target_number: int,
|
||||
edge_type: str,
|
||||
state: str,
|
||||
blocking_condition: str | None = None,
|
||||
completion_condition: str | None = None,
|
||||
evidence: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert or refresh one dependency edge, keyed by its relationship.
|
||||
|
||||
Uniqueness is (scope, source, target, edge_type), so re-observing the
|
||||
same relationship updates one row instead of appending history — the
|
||||
edge is current state, and transitions are recorded as ``events``.
|
||||
|
||||
Edge type, state, and both endpoint kinds are validated fail-closed;
|
||||
an unknown value writes nothing. Evidence is sanitized before storage.
|
||||
"""
|
||||
edge_type_norm = dependency_graph.normalize_edge_type(edge_type)
|
||||
state_norm = dependency_graph.normalize_edge_state(state)
|
||||
source_kind_norm = dependency_graph.normalize_work_kind(source_kind)
|
||||
target_kind_norm = dependency_graph.normalize_work_kind(target_kind)
|
||||
source_no = int(source_number)
|
||||
target_no = int(target_number)
|
||||
if blocking_condition is None or completion_condition is None:
|
||||
defaults = dependency_graph.default_conditions(edge_type_norm)
|
||||
blocking_condition = (
|
||||
defaults[0] if blocking_condition is None else blocking_condition
|
||||
)
|
||||
completion_condition = (
|
||||
defaults[1] if completion_condition is None else completion_condition
|
||||
)
|
||||
evidence_json = json.dumps(
|
||||
dependency_graph.sanitize_evidence(evidence if evidence is not None else {})
|
||||
)
|
||||
now_s = _ts()
|
||||
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"""
|
||||
SELECT * FROM dependency_edges
|
||||
WHERE remote = ? AND org = ? AND repo = ?
|
||||
AND source_kind = ? AND source_number = ?
|
||||
AND target_kind = ? AND target_number = ? AND edge_type = ?
|
||||
""",
|
||||
(
|
||||
remote,
|
||||
org,
|
||||
repo,
|
||||
source_kind_norm,
|
||||
source_no,
|
||||
target_kind_norm,
|
||||
target_no,
|
||||
edge_type_norm,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
edge_id = uuid.uuid4().hex
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dependency_edges(
|
||||
edge_id, remote, org, repo,
|
||||
source_kind, source_number, target_kind, target_number,
|
||||
edge_type, blocking_condition, completion_condition,
|
||||
state, evidence, created_at, updated_at, last_observed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
edge_id,
|
||||
remote,
|
||||
org,
|
||||
repo,
|
||||
source_kind_norm,
|
||||
source_no,
|
||||
target_kind_norm,
|
||||
target_no,
|
||||
edge_type_norm,
|
||||
blocking_condition,
|
||||
completion_condition,
|
||||
state_norm,
|
||||
evidence_json,
|
||||
now_s,
|
||||
now_s,
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
else:
|
||||
edge_id = str(existing["edge_id"])
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE dependency_edges
|
||||
SET blocking_condition = ?, completion_condition = ?,
|
||||
state = ?, evidence = ?, updated_at = ?,
|
||||
last_observed_at = ?
|
||||
WHERE edge_id = ?
|
||||
""",
|
||||
(
|
||||
blocking_condition,
|
||||
completion_condition,
|
||||
state_norm,
|
||||
evidence_json,
|
||||
now_s,
|
||||
now_s,
|
||||
edge_id,
|
||||
),
|
||||
)
|
||||
prior_state = str(existing["state"])
|
||||
if prior_state != state_norm:
|
||||
self._record_edge_transition_conn(
|
||||
conn,
|
||||
edge_id=edge_id,
|
||||
prior_state=prior_state,
|
||||
new_state=state_norm,
|
||||
detail="observed during upsert",
|
||||
now_s=now_s,
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
return self._dependency_edge_row(row) or {}
|
||||
|
||||
@staticmethod
|
||||
def _record_edge_transition_conn(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
edge_id: str,
|
||||
prior_state: str,
|
||||
new_state: str,
|
||||
detail: str,
|
||||
now_s: str,
|
||||
) -> None:
|
||||
"""Append a state transition to the shared ``events`` audit table.
|
||||
|
||||
``work_item_id`` stays NULL: an edge endpoint is a Gitea issue/PR that
|
||||
may never have been assigned, so it has no work_items row to reference.
|
||||
"""
|
||||
message = (
|
||||
f"dependency edge {edge_id} state {prior_state} -> {new_state}"
|
||||
f" ({detail})"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (NULL, 'dependency_edge_state_change', ?, ?)
|
||||
""",
|
||||
(message, now_s),
|
||||
)
|
||||
|
||||
def list_dependency_edges(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
source_kind: str | None = None,
|
||||
source_number: int | None = None,
|
||||
target_kind: str | None = None,
|
||||
target_number: int | None = None,
|
||||
edge_type: str | None = None,
|
||||
state: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return stored edges, filtered.
|
||||
|
||||
Filtering by *target* answers "what is waiting on this work unit",
|
||||
which is the query automatic resumption needs and which body-text
|
||||
parsing could never serve.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if remote:
|
||||
clauses.append("remote = ?")
|
||||
params.append(remote)
|
||||
if org:
|
||||
clauses.append("org = ?")
|
||||
params.append(org)
|
||||
if repo:
|
||||
clauses.append("repo = ?")
|
||||
params.append(repo)
|
||||
if source_kind:
|
||||
clauses.append("source_kind = ?")
|
||||
params.append(dependency_graph.normalize_work_kind(source_kind))
|
||||
if source_number is not None:
|
||||
clauses.append("source_number = ?")
|
||||
params.append(int(source_number))
|
||||
if target_kind:
|
||||
clauses.append("target_kind = ?")
|
||||
params.append(dependency_graph.normalize_work_kind(target_kind))
|
||||
if target_number is not None:
|
||||
clauses.append("target_number = ?")
|
||||
params.append(int(target_number))
|
||||
if edge_type:
|
||||
clauses.append("edge_type = ?")
|
||||
params.append(dependency_graph.normalize_edge_type(edge_type))
|
||||
if state:
|
||||
clauses.append("state = ?")
|
||||
params.append(dependency_graph.normalize_edge_state(state))
|
||||
|
||||
sql = "SELECT * FROM dependency_edges"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY source_number ASC, target_number ASC, edge_type ASC LIMIT ?"
|
||||
params.append(int(limit))
|
||||
|
||||
with self._tx(immediate=False) as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [edge for edge in (self._dependency_edge_row(r) for r in rows) if edge]
|
||||
|
||||
def record_dependency_edge_observation(
|
||||
self,
|
||||
edge_id: str,
|
||||
*,
|
||||
state: str,
|
||||
evidence: Any = None,
|
||||
detail: str = "observation recorded",
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing edge's state and evidence, auditing the change.
|
||||
|
||||
A transition writes an ``events`` row carrying both the prior and the
|
||||
new state, so a later blocked/resume decision can be reconstructed from
|
||||
durable state rather than from a recomputed reason string.
|
||||
"""
|
||||
state_norm = dependency_graph.normalize_edge_state(state)
|
||||
now_s = _ts()
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
raise ControlPlaneError(
|
||||
f"dependency edge '{edge_id}' does not exist (fail closed)"
|
||||
)
|
||||
prior_state = str(existing["state"])
|
||||
if evidence is None:
|
||||
evidence_json = str(existing["evidence"] or "{}")
|
||||
else:
|
||||
evidence_json = json.dumps(
|
||||
dependency_graph.sanitize_evidence(evidence)
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE dependency_edges
|
||||
SET state = ?, evidence = ?, updated_at = ?, last_observed_at = ?
|
||||
WHERE edge_id = ?
|
||||
""",
|
||||
(state_norm, evidence_json, now_s, now_s, edge_id),
|
||||
)
|
||||
if prior_state != state_norm:
|
||||
self._record_edge_transition_conn(
|
||||
conn,
|
||||
edge_id=edge_id,
|
||||
prior_state=prior_state,
|
||||
new_state=state_norm,
|
||||
detail=detail,
|
||||
now_s=now_s,
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dependency_edges WHERE edge_id = ?", (edge_id,)
|
||||
).fetchone()
|
||||
edge = self._dependency_edge_row(row) or {}
|
||||
edge["prior_state"] = prior_state
|
||||
edge["state_changed"] = prior_state != state_norm
|
||||
return edge
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Durable dependency-edge vocabulary for the control plane (#784, umbrella #628).
|
||||
|
||||
Umbrella #628 scope item 6 requires dependencies to be durable structured state
|
||||
carrying source, target, type, blocking condition, completion condition, current
|
||||
state, and evidence. Before this module the only dependency knowledge in the
|
||||
system was the per-run parse performed by :mod:`allocator_dependencies`, which
|
||||
collapsed into two in-memory ``WorkCandidate`` fields and was then discarded.
|
||||
|
||||
This module owns the vocabulary half of that store:
|
||||
|
||||
* the seven relationship types #628 enumerates;
|
||||
* the three observation states, matching the outcome of
|
||||
:func:`allocator_dependencies.resolve_dependency_state`;
|
||||
* fail-closed normalization for both, plus for work kinds;
|
||||
* the default blocking/completion condition text for each type;
|
||||
* evidence sanitization, so no credential or endpoint ever reaches the store.
|
||||
|
||||
Persistence lives in :mod:`control_plane_db`; ingestion from a live allocation
|
||||
run is :func:`record_issue_dependency_edges`. Nothing here changes allocator
|
||||
selection — this slice records the graph, it does not act on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
# --- Work kinds -------------------------------------------------------------
|
||||
# Mirrors control_plane_db.WORK_KINDS. Declared locally so this module stays
|
||||
# import-light and usable from the DB layer without a circular import.
|
||||
WORK_KIND_ISSUE = "issue"
|
||||
WORK_KIND_PR = "pr"
|
||||
WORK_KINDS = frozenset({WORK_KIND_ISSUE, WORK_KIND_PR})
|
||||
|
||||
# --- Edge types (#628 scope item 6) -----------------------------------------
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE = "issue_blocked_by_issue"
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES = "pr_waiting_for_requested_changes"
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL = "merge_waiting_for_approval"
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE = "reconciliation_waiting_for_merge"
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE = "deployment_waiting_for_infrastructure"
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION = "acceptance_waiting_for_validation"
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX = "task_waiting_for_defect_fix"
|
||||
|
||||
EDGE_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL,
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE,
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Edge states ------------------------------------------------------------
|
||||
# Deliberately three-valued: unavailable evidence is never recorded as met,
|
||||
# matching resolve_dependency_state's fail-closed contract (#758 AC6/AC7).
|
||||
STATE_UNMET = "unmet"
|
||||
STATE_MET = "met"
|
||||
STATE_UNAVAILABLE = "unavailable"
|
||||
|
||||
EDGE_STATES: frozenset[str] = frozenset({STATE_UNMET, STATE_MET, STATE_UNAVAILABLE})
|
||||
|
||||
# Default condition text per edge type: (blocking_condition, completion_condition).
|
||||
DEFAULT_CONDITIONS: dict[str, tuple[str, str]] = {
|
||||
EDGE_ISSUE_BLOCKED_BY_ISSUE: (
|
||||
"target issue is not closed",
|
||||
"target issue is closed",
|
||||
),
|
||||
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES: (
|
||||
"requested changes are outstanding at the current head",
|
||||
"requested changes are addressed at the current head",
|
||||
),
|
||||
EDGE_MERGE_WAITING_FOR_APPROVAL: (
|
||||
"no approval exists at the current head",
|
||||
"an approval exists at the current head",
|
||||
),
|
||||
EDGE_RECONCILIATION_WAITING_FOR_MERGE: (
|
||||
"target pull request is not merged",
|
||||
"target pull request is merged",
|
||||
),
|
||||
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE: (
|
||||
"required infrastructure is unavailable",
|
||||
"required infrastructure is available",
|
||||
),
|
||||
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION: (
|
||||
"required validation evidence is missing",
|
||||
"required validation evidence is recorded",
|
||||
),
|
||||
EDGE_TASK_WAITING_FOR_DEFECT_FIX: (
|
||||
"blocking defect is unresolved or undeployed",
|
||||
"blocking defect is fixed and the runtime carries the fix",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DependencyGraphError(ValueError):
|
||||
"""Base error for dependency-edge vocabulary violations."""
|
||||
|
||||
|
||||
class InvalidEdgeTypeError(DependencyGraphError):
|
||||
"""Raised when an edge type outside :data:`EDGE_TYPES` is supplied."""
|
||||
|
||||
|
||||
class InvalidEdgeStateError(DependencyGraphError):
|
||||
"""Raised when a state outside :data:`EDGE_STATES` is supplied."""
|
||||
|
||||
|
||||
class InvalidEdgeEndpointError(DependencyGraphError):
|
||||
"""Raised when an edge endpoint is not an assignable work unit."""
|
||||
|
||||
|
||||
def normalize_edge_type(value: Any) -> str:
|
||||
"""Return the canonical edge type, or raise fail-closed.
|
||||
|
||||
Unknown values are never coerced to a default: an unrecognized relationship
|
||||
would be stored as an unqueryable free-text row and would silently break
|
||||
reverse lookup for whichever consumer expected the real type.
|
||||
"""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in EDGE_TYPES:
|
||||
raise InvalidEdgeTypeError(
|
||||
f"unknown dependency edge_type '{value}'; expected one of "
|
||||
f"{sorted(EDGE_TYPES)} (fail closed)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_edge_state(value: Any) -> str:
|
||||
"""Return the canonical edge state, or raise fail-closed."""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in EDGE_STATES:
|
||||
raise InvalidEdgeStateError(
|
||||
f"unknown dependency edge state '{value}'; expected one of "
|
||||
f"{sorted(EDGE_STATES)} (fail closed)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_work_kind(value: Any) -> str:
|
||||
"""Return the canonical work kind for an edge endpoint, or raise."""
|
||||
text = str(value or "").strip().lower()
|
||||
if text not in WORK_KINDS:
|
||||
raise InvalidEdgeEndpointError(
|
||||
f"dependency edge endpoint kind '{value}' is not assignable work; "
|
||||
f"expected one of {sorted(WORK_KINDS)} (never raw incidents)"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def default_conditions(edge_type: str) -> tuple[str, str]:
|
||||
"""Return ``(blocking_condition, completion_condition)`` for *edge_type*."""
|
||||
return DEFAULT_CONDITIONS[normalize_edge_type(edge_type)]
|
||||
|
||||
|
||||
# --- Evidence sanitization --------------------------------------------------
|
||||
|
||||
_SECRET_KEY_PATTERN = re.compile(
|
||||
r"token|secret|password|passwd|authorization|auth_header|credential|api_key"
|
||||
r"|apikey|private_key|cookie|session_token",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
|
||||
|
||||
REDACTED = "[redacted]"
|
||||
|
||||
# Evidence is a small observation record; a deep or huge payload is a sign the
|
||||
# caller is dumping API responses into the store.
|
||||
_MAX_EVIDENCE_DEPTH = 6
|
||||
_MAX_EVIDENCE_STRING = 2000
|
||||
|
||||
|
||||
def sanitize_evidence(payload: Any, *, _depth: int = 0) -> Any:
|
||||
"""Return *payload* with credentials and endpoint URLs removed.
|
||||
|
||||
Applies to every stored evidence record. Keys naming a secret are replaced
|
||||
wholesale; any value containing a URL has the URL replaced, so an endpoint
|
||||
can never be persisted or handed back through a read tool.
|
||||
"""
|
||||
if _depth > _MAX_EVIDENCE_DEPTH:
|
||||
return REDACTED
|
||||
if isinstance(payload, Mapping):
|
||||
clean: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
name = str(key)
|
||||
if _SECRET_KEY_PATTERN.search(name):
|
||||
clean[name] = REDACTED
|
||||
else:
|
||||
clean[name] = sanitize_evidence(value, _depth=_depth + 1)
|
||||
return clean
|
||||
if isinstance(payload, (list, tuple)):
|
||||
return [sanitize_evidence(item, _depth=_depth + 1) for item in payload]
|
||||
if isinstance(payload, str):
|
||||
text = _URL_PATTERN.sub(REDACTED, payload)
|
||||
if len(text) > _MAX_EVIDENCE_STRING:
|
||||
text = text[:_MAX_EVIDENCE_STRING] + "…"
|
||||
return text
|
||||
if isinstance(payload, (int, float, bool)) or payload is None:
|
||||
return payload
|
||||
return sanitize_evidence(str(payload), _depth=_depth + 1)
|
||||
|
||||
|
||||
# --- Ingestion from a live allocation run -----------------------------------
|
||||
|
||||
# Observed live state as recorded in evidence. The exact Gitea state string is
|
||||
# not stored for the unmet case: resolve_dependency_state has already reduced
|
||||
# "any live value other than closed" to unmet, and re-deriving it here would
|
||||
# invent evidence the resolver never produced.
|
||||
OBSERVED_CLOSED = "closed"
|
||||
OBSERVED_NOT_CLOSED = "not_closed"
|
||||
OBSERVED_UNAVAILABLE = "unavailable"
|
||||
|
||||
OBSERVATION_SOURCE_ALLOCATOR = "allocator_live_issue_lookup"
|
||||
|
||||
_OBSERVED_STATE_BY_EDGE_STATE = {
|
||||
STATE_MET: OBSERVED_CLOSED,
|
||||
STATE_UNMET: OBSERVED_NOT_CLOSED,
|
||||
STATE_UNAVAILABLE: OBSERVED_UNAVAILABLE,
|
||||
}
|
||||
|
||||
|
||||
def _observation(state: str, *, observed_by: str | None, subject: str) -> dict[str, Any]:
|
||||
return {
|
||||
"observed_state": _OBSERVED_STATE_BY_EDGE_STATE[state],
|
||||
"observation_source": OBSERVATION_SOURCE_ALLOCATOR,
|
||||
"observed_by_session": observed_by,
|
||||
"declaration": "Depends declaration in issue body",
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
|
||||
def edges_from_dependency_resolution(
|
||||
resolution: Mapping[str, Any],
|
||||
*,
|
||||
source_number: int,
|
||||
observed_by: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert one resolver result into edge records ready for persistence.
|
||||
|
||||
*resolution* is the dict returned by
|
||||
:func:`allocator_dependencies.resolve_dependency_state`. Its ``met`` /
|
||||
``unmet`` / ``unavailable`` partitions map one-to-one onto the stored
|
||||
states, so no dependency is re-classified here.
|
||||
"""
|
||||
subject = f"issue#{int(source_number)}"
|
||||
blocking, completion = default_conditions(EDGE_ISSUE_BLOCKED_BY_ISSUE)
|
||||
records: list[dict[str, Any]] = []
|
||||
partitions: tuple[tuple[str, Iterable[Any]], ...] = (
|
||||
(STATE_MET, resolution.get("met") or ()),
|
||||
(STATE_UNMET, resolution.get("unmet") or ()),
|
||||
(STATE_UNAVAILABLE, resolution.get("unavailable") or ()),
|
||||
)
|
||||
for state, refs in partitions:
|
||||
for ref in refs:
|
||||
records.append(
|
||||
{
|
||||
"source_kind": WORK_KIND_ISSUE,
|
||||
"source_number": int(source_number),
|
||||
"target_kind": WORK_KIND_ISSUE,
|
||||
"target_number": int(ref),
|
||||
"edge_type": EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
"state": state,
|
||||
"blocking_condition": blocking,
|
||||
"completion_condition": completion,
|
||||
"evidence": _observation(
|
||||
state, observed_by=observed_by, subject=subject
|
||||
),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def record_issue_dependency_edges(
|
||||
db: Any,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
source_number: int,
|
||||
resolution: Mapping[str, Any],
|
||||
observed_by: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Persist the edges implied by one candidate's dependency resolution.
|
||||
|
||||
Best-effort by contract: allocation correctness must not depend on this
|
||||
store existing or being writable, so every failure is returned as a reason
|
||||
string and never raised. The caller keeps using the in-memory resolution it
|
||||
already holds.
|
||||
"""
|
||||
try:
|
||||
records = edges_from_dependency_resolution(
|
||||
resolution, source_number=source_number, observed_by=observed_by
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — ingestion never breaks allocation
|
||||
return [f"dependency edge ingestion skipped for issue#{source_number}: {exc}"]
|
||||
|
||||
reasons: list[str] = []
|
||||
for record in records:
|
||||
try:
|
||||
db.upsert_dependency_edge(remote=remote, org=org, repo=repo, **record)
|
||||
except Exception as exc: # noqa: BLE001 — see docstring
|
||||
reasons.append(
|
||||
f"dependency edge not persisted for issue#{source_number} → "
|
||||
f"issue#{record['target_number']}: {exc}"
|
||||
)
|
||||
return reasons
|
||||
@@ -171,13 +171,18 @@ then:
|
||||
- Does not replace CI or code review for MCP changes
|
||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||
|
||||
## 5. Implementation follow-ups (optional tooling)
|
||||
## 5. Implementation follow-ups
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
The **policy binds sessions now**. The enforcement layer landed with issue #615
|
||||
acceptance criteria 6–11 in `stable_control_runtime.py`:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
|
||||
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
|
||||
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
|
||||
|
||||
Post-transport-flap proof is enforced per namespace: a flap invalidates every
|
||||
`gitea-*` namespace at once, and author proof never transfers to reviewer,
|
||||
merger, or reconciler (`namespace_not_reproven_after_flap`).
|
||||
|
||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||
|
||||
|
||||
@@ -131,6 +131,44 @@ Suggested lifecycle:
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Terminal PR transitions retire `status:pr-open` (#780)
|
||||
|
||||
`status:pr-open` states that a linked PR is *currently open*. The moment that
|
||||
stops being true the label must go, whatever ended the PR:
|
||||
|
||||
| Terminal reason | Raised by |
|
||||
|---|---|
|
||||
| `merged` | `gitea_merge_pr` |
|
||||
| `closed_without_merge` | `gitea_edit_pr` closing the PR |
|
||||
| `superseded` | `gitea_reconcile_superseded_by_merged_pr` |
|
||||
| `already_landed` | `gitea_reconcile_already_landed_pr` |
|
||||
| `controller_closure` | `gitea_close_issue` |
|
||||
| `abandoned` | abandonment handling |
|
||||
| `retry_recovery` | `gitea_cleanup_terminal_pr_labels` after a partial failure |
|
||||
|
||||
All of these route through one rule in `terminal_pr_label_cleanup.py`, so the
|
||||
paths cannot drift apart. The rule guarantees:
|
||||
|
||||
- only `status:pr-open` is removed — every other label is preserved verbatim;
|
||||
- an empty resulting label set is valid (it was the issue's only label);
|
||||
- an issue that no longer carries the label is a no-op, so retries are safe;
|
||||
- the result is confirmed by a read-after-write re-read, not assumed.
|
||||
|
||||
Controller closure runs the cleanup **before** changing issue state and fails
|
||||
closed if it cannot be completed and verified — closing first would bake in the
|
||||
stale label with no later step to catch it. Post-merge cleanup never blocks the
|
||||
merge: the transition already happened, so failures are reported with a
|
||||
`safe_next_action` instead.
|
||||
|
||||
Use `gitea_assess_terminal_label_hygiene` as terminal validation before
|
||||
declaring a transition or cleanup batch complete. It enumerates issues plus the
|
||||
live open PRs and reports any issue still carrying `status:pr-open` without an
|
||||
open PR to justify it. Issues with a genuinely open PR are exempt, not
|
||||
residual.
|
||||
|
||||
Recovery from a partial failure is `gitea_cleanup_terminal_pr_labels` with
|
||||
`terminal_reason='retry_recovery'`.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
@@ -157,6 +195,10 @@ If a discussion produces implementation work, either:
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
- `gitea_cleanup_terminal_pr_labels` retires `status:pr-open` after a terminal
|
||||
PR transition; it is idempotent, so it is also the retry/recovery path.
|
||||
- `gitea_assess_terminal_label_hygiene` is the read-only terminal validation
|
||||
for residual `status:pr-open`.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
|
||||
@@ -706,7 +706,9 @@ do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`status:pr-open` (every terminal transition later retires that label
|
||||
automatically — see [`label-taxonomy.md`](label-taxonomy.md)). **Do not**
|
||||
review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
@@ -1241,6 +1243,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
|
||||
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
### Workflow dashboard MCP tool (#605)
|
||||
|
||||
From any healthy Gitea MCP namespace with `gitea.read`:
|
||||
|
||||
```text
|
||||
gitea_workflow_dashboard(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
```
|
||||
|
||||
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
|
||||
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
|
||||
`primary_next_safe_action`. Incomplete inventory fails closed.
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
|
||||
@@ -110,8 +110,49 @@ healthy. See `docs/mcp-namespace-health.md`.
|
||||
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||
reconnect.
|
||||
|
||||
## Sanctioned recovery vs forbidden process manipulation (#630)
|
||||
|
||||
Both restore a working namespace. Only one leaves the session trustworthy.
|
||||
|
||||
**Sanctioned — the runtime is repaired by whoever owns it:**
|
||||
|
||||
- IDE/host auto-reconnect, or an explicit client reconnect (`/mcp reconnect`).
|
||||
- Relaunching the IDE/client so it respawns the daemons it started.
|
||||
- An operator-owned restart performed outside the workflow session.
|
||||
|
||||
**Forbidden — the session manipulates the processes its own proof depends on:**
|
||||
|
||||
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
|
||||
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
|
||||
- Any pattern broad enough to take unrelated namespaces with it
|
||||
(`pkill -f python`), even when it never names MCP.
|
||||
|
||||
Read-only inspection (`ps aux | grep mcp_server`) is neither: it proves nothing
|
||||
and breaks nothing. A `kill` of some unrelated pid is reported as *ambiguous*
|
||||
rather than contaminating, so ordinary subprocess work is never false-blocked.
|
||||
|
||||
**What happens on a detected attempt.** `gitea_record_daemon_process_kill_attempt`
|
||||
classifies a proposed command and, when it is a manual daemon kill, writes a
|
||||
durable contamination marker for the active profile identity. While that marker
|
||||
is live every review / merge / close / completion mutation fails closed;
|
||||
`comment_issue` and `lock_issue` stay allowed so the contaminated worker can
|
||||
still post its audit comment and hand off. The final report must surface the
|
||||
contaminated recovery and must not claim a clean session.
|
||||
|
||||
Contamination is **not self-clearable**. Only
|
||||
`gitea_audit_runtime_recovery_contamination` with `action=clear`, run under a
|
||||
reconciler profile, removes it. The marker is recovery-critical, so it does not
|
||||
expire into cleanliness when the session-state TTL lapses.
|
||||
|
||||
**Operator-authorized host maintenance stays permitted.** Authorization is read
|
||||
from the `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` environment variable
|
||||
and from nowhere else — set outside the session by the operator who owns the
|
||||
host, and recorded as an audit reference on the assessment. It is deliberately
|
||||
not a tool argument: a session must never be able to authorize itself.
|
||||
|
||||
## Related
|
||||
|
||||
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
||||
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Registered MCP tool inventory
|
||||
|
||||
This is the canonical list of tools the Gitea-Tools MCP server registers. It
|
||||
exists because documentation and the registered inventory drifted: the workflow
|
||||
documented a `gitea_edit_issue` tool that no namespace had ever registered, so a
|
||||
mutation could be planned against a tool that did not exist and only fail at
|
||||
execution time (#781).
|
||||
|
||||
## The rule
|
||||
|
||||
**Documentation must never name a tool an actor cannot reach.**
|
||||
|
||||
Two guards enforce it, both in `tests/test_issue_781_edit_issue_tool.py`:
|
||||
|
||||
1. The list below must equal the registered tool set exactly — sorted, no
|
||||
duplicates, nothing missing in either direction. Adding a tool without
|
||||
documenting it fails, and documenting a tool without registering it fails.
|
||||
2. Every backticked `gitea_*` / `mcp_*` identifier in `skills/**/*.md` must be a
|
||||
registered tool. Module and script names that share the prefix are listed
|
||||
explicitly in `mcp_tool_inventory.NON_TOOL_IDENTIFIERS` rather than being
|
||||
waved through by a looser pattern.
|
||||
|
||||
## Updating this file
|
||||
|
||||
When you add or remove an `@mcp.tool()`, regenerate the block below:
|
||||
|
||||
```bash
|
||||
PYTEST_CURRENT_TEST=1 venv/bin/python -c "
|
||||
import mcp_server, mcp_tool_inventory
|
||||
print(mcp_tool_inventory.render_inventory_block(
|
||||
mcp_server.mcp._tool_manager._tools))
|
||||
"
|
||||
```
|
||||
|
||||
Replace everything between the markers with that output. Do not hand-edit
|
||||
individual entries — the generator and the guard share one ordering rule.
|
||||
|
||||
## Registered tools
|
||||
|
||||
Namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`)
|
||||
register the same tool set; what differs per namespace is the execution profile
|
||||
that gates each call, not which tools exist.
|
||||
|
||||
<!-- BEGIN REGISTERED TOOL INVENTORY -->
|
||||
|
||||
- `gitea_abandon_workflow_lease`
|
||||
- `gitea_acquire_conflict_fix_lease`
|
||||
- `gitea_acquire_merger_pr_lease`
|
||||
- `gitea_acquire_reviewer_pr_lease`
|
||||
- `gitea_activate_profile`
|
||||
- `gitea_adopt_merger_pr_lease`
|
||||
- `gitea_adopt_workflow_lease`
|
||||
- `gitea_allocate_next_work`
|
||||
- `gitea_assess_already_landed_reconciliation`
|
||||
- `gitea_assess_conflict_fix_classification`
|
||||
- `gitea_assess_conflict_fix_push`
|
||||
- `gitea_assess_gitea_operation_path`
|
||||
- `gitea_assess_master_parity`
|
||||
- `gitea_assess_mcp_namespace_health`
|
||||
- `gitea_assess_pr_sync_status`
|
||||
- `gitea_assess_review_merge_state_machine`
|
||||
- `gitea_assess_reviewer_pr_lease`
|
||||
- `gitea_assess_terminal_label_hygiene`
|
||||
- `gitea_assess_work_issue_duplicate`
|
||||
- `gitea_assess_worktree_cleanup_integrity`
|
||||
- `gitea_audit_config`
|
||||
- `gitea_audit_runtime_recovery_contamination`
|
||||
- `gitea_audit_stable_branch_contamination`
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
- `gitea_authorize_review_correction`
|
||||
- `gitea_capability_stop_terminal_report`
|
||||
- `gitea_capture_branches_worktree_snapshot`
|
||||
- `gitea_check_pr_eligibility`
|
||||
- `gitea_cleanup_merged_pr_branch`
|
||||
- `gitea_cleanup_obsolete_reviewer_comment_lease`
|
||||
- `gitea_cleanup_post_merge_moot_lease`
|
||||
- `gitea_cleanup_stale_claims`
|
||||
- `gitea_cleanup_stale_review_decision_lock`
|
||||
- `gitea_cleanup_terminal_pr_labels`
|
||||
- `gitea_close_issue`
|
||||
- `gitea_commit_files`
|
||||
- `gitea_consume_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_create_issue`
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_create_label`
|
||||
- `gitea_create_pr`
|
||||
- `gitea_delete_branch`
|
||||
- `gitea_diagnose_review_decision_lock`
|
||||
- `gitea_diagnose_reviewer_pr_lease_handoff`
|
||||
- `gitea_diagnose_terminal`
|
||||
- `gitea_dry_run_pr_review`
|
||||
- `gitea_edit_issue`
|
||||
- `gitea_edit_pr`
|
||||
- `gitea_expire_workflow_leases`
|
||||
- `gitea_get_authenticated_user`
|
||||
- `gitea_get_current_user`
|
||||
- `gitea_get_file`
|
||||
- `gitea_get_pr_review_feedback`
|
||||
- `gitea_get_profile`
|
||||
- `gitea_get_runtime_context`
|
||||
- `gitea_get_shell_health`
|
||||
- `gitea_heartbeat_reviewer_pr_lease`
|
||||
- `gitea_inspect_workflow_lease`
|
||||
- `gitea_issue_irrecoverable_provenance_authorization`
|
||||
- `gitea_list_dependency_edges`
|
||||
- `gitea_list_issue_comments`
|
||||
- `gitea_list_issues`
|
||||
- `gitea_list_labels`
|
||||
- `gitea_list_profiles`
|
||||
- `gitea_list_prs`
|
||||
- `gitea_list_workflow_leases`
|
||||
- `gitea_load_review_workflow`
|
||||
- `gitea_lock_issue`
|
||||
- `gitea_mark_final_review_decision`
|
||||
- `gitea_mark_issue`
|
||||
- `gitea_merge_pr`
|
||||
- `gitea_mirror_refs`
|
||||
- `gitea_observability_link_issue`
|
||||
- `gitea_observability_list_projects`
|
||||
- `gitea_observability_reconcile_incident`
|
||||
- `gitea_post_heartbeat`
|
||||
- `gitea_quarantine_contaminated_review`
|
||||
- `gitea_reclaim_expired_workflow_lease`
|
||||
- `gitea_reconcile_already_landed_pr`
|
||||
- `gitea_reconcile_issue_claims`
|
||||
- `gitea_reconcile_merged_cleanups`
|
||||
- `gitea_reconcile_superseded_by_merged_pr`
|
||||
- `gitea_record_daemon_process_kill_attempt`
|
||||
- `gitea_record_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_record_pre_review_command`
|
||||
- `gitea_record_shell_spawn_outcome`
|
||||
- `gitea_record_stable_branch_push_attempt`
|
||||
- `gitea_release_merger_pr_lease`
|
||||
- `gitea_release_reviewer_pr_lease`
|
||||
- `gitea_release_workflow_lease`
|
||||
- `gitea_resolve_task_capability`
|
||||
- `gitea_resume_review_draft`
|
||||
- `gitea_review_pr`
|
||||
- `gitea_route_task_session`
|
||||
- `gitea_save_review_draft`
|
||||
- `gitea_scan_already_landed_open_prs`
|
||||
- `gitea_sentry_get_issue_events`
|
||||
- `gitea_sentry_link_gitea_issue`
|
||||
- `gitea_sentry_list_issues`
|
||||
- `gitea_sentry_reconcile_issue`
|
||||
- `gitea_sentry_watchdog`
|
||||
- `gitea_set_issue_labels`
|
||||
- `gitea_submit_pr_review`
|
||||
- `gitea_update_pr_branch_by_merge`
|
||||
- `gitea_validate_review_final_report`
|
||||
- `gitea_view_issue`
|
||||
- `gitea_view_pr`
|
||||
- `gitea_whoami`
|
||||
- `gitea_workflow_dashboard`
|
||||
- `mcp_check_workflow_skill_preflight`
|
||||
- `mcp_get_control_plane_guide`
|
||||
- `mcp_get_skill_guide`
|
||||
- `mcp_list_project_skills`
|
||||
|
||||
<!-- END REGISTERED TOOL INVENTORY -->
|
||||
|
||||
## Issue-content editing
|
||||
|
||||
`gitea_edit_issue` is the only path that changes an issue's title or body. It
|
||||
PATCHes the issue endpoint, refuses a pull-request number, sends only the fields
|
||||
the caller named, and proves the result by read-after-write — including that
|
||||
state, labels, assignees, and milestone did not move.
|
||||
|
||||
`gitea_edit_pr` remains pull-request-only. The two paths never merge: a single
|
||||
tool that accepted either kind would make the narrower capability reachable
|
||||
through the wider one.
|
||||
@@ -131,7 +131,59 @@ and `incident_links` rows.
|
||||
- The bridge remains the **only** sanctioned route from an alert back into
|
||||
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** 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 merger_lease_adoption
|
||||
import reviewer_handoff_consistency
|
||||
import runtime_recovery_guard
|
||||
import thread_state_ledger_validator
|
||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from self_propagating_handoff import (
|
||||
HANDOFF_HEADING as SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
assess_final_report_self_propagating_handoff,
|
||||
)
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -728,6 +733,65 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
_MUTATION_ACCOUNTING_PATTERNS = {
|
||||
"local_failed_attempts": re.compile(
|
||||
r"local\s+failed\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"blocked_api_attempts": re.compile(
|
||||
r"blocked\s+api\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"successful_server_mutations": re.compile(
|
||||
r"successful\s+server(?:[-\s]side)?\s+mutations\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
_READBACK_VERIFIED_PATTERN = re.compile(
|
||||
r"read[-\s]?after[-\s]?write\s+verified\s*:\s*(yes|true)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_mutation_budget_accounting(
|
||||
report_text: str,
|
||||
*,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#617: mutation budget counts server-side changes only.
|
||||
|
||||
No-op unless the session supplies an attempt ledger. When it does, the
|
||||
report's three attempt categories must match the ledger exactly, so a
|
||||
pre-API validator rejection can never be reported as a Gitea mutation and
|
||||
a real mutation can never be hidden.
|
||||
"""
|
||||
if mutation_attempt_ledger is None:
|
||||
return []
|
||||
|
||||
from mutation_budget_classifier import assess_final_report_mutation_accounting
|
||||
|
||||
text = report_text or ""
|
||||
claimed: dict[str, Any] = {}
|
||||
for field, pattern in _MUTATION_ACCOUNTING_PATTERNS.items():
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
claimed[field] = int(match.group(1))
|
||||
if _READBACK_VERIFIED_PATTERN.search(text):
|
||||
claimed["readback_verified"] = True
|
||||
|
||||
result = assess_final_report_mutation_accounting(claimed, mutation_attempt_ledger)
|
||||
if result.get("valid"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.mutation_budget_accounting",
|
||||
result.get("reasons") or [],
|
||||
field="Mutation accounting",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"report 'Local failed attempts:', 'Blocked API attempts:', and "
|
||||
"'Successful server-side mutations:' with counts matching the "
|
||||
"attempt ledger; pre-API rejections are not Gitea mutations"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report,
|
||||
@@ -1564,6 +1628,21 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_self_propagating_handoff(report_text: str) -> list[dict[str, str]]:
|
||||
"""#626: a report that adopts the handoff protocol must complete it."""
|
||||
result = assess_final_report_self_propagating_handoff(report_text)
|
||||
if not result.get("applicable") or not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.self_propagating_handoff",
|
||||
result.get("reasons") or ["incomplete canonical handoff"],
|
||||
field=SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "complete every canonical handoff field before posting",
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
@@ -1584,13 +1663,24 @@ _SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_SHARED_MUTATION_BUDGET_RULES = (
|
||||
_rule_shared_mutation_budget_accounting,
|
||||
)
|
||||
|
||||
# #626: enforced for every task kind that can continue the workflow chain.
|
||||
_SHARED_SELF_PROPAGATING_HANDOFF_RULES = (
|
||||
_rule_shared_self_propagating_handoff,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1620,6 +1710,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"merge_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
@@ -1631,11 +1722,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1650,20 +1743,24 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_audit_reconciliation_boundary,
|
||||
],
|
||||
"author_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1672,28 +1769,34 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_worktree_cleanup_audit_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
# Controller issue closure (#529): a closure report must not bury an
|
||||
@@ -1701,6 +1804,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||
# full reviewer/author handoff schema.
|
||||
"controller_close": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reviewer_premerge_baseline_proof,
|
||||
],
|
||||
}
|
||||
@@ -1766,6 +1870,8 @@ def assess_final_report_validator(
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
runtime_recovery_marker: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1804,6 +1910,28 @@ def assess_final_report_validator(
|
||||
action_log = sanitized_action_log
|
||||
findings.extend(action_log_findings)
|
||||
|
||||
# #630 scope item 4: while a manual daemon-kill contamination marker is
|
||||
# live, the report must surface it and must not claim a clean session.
|
||||
if runtime_recovery_marker:
|
||||
runtime_recovery = runtime_recovery_guard.assess_final_report_claim(
|
||||
report_text,
|
||||
runtime_recovery_marker,
|
||||
)
|
||||
checks["runtime_recovery_contamination"] = runtime_recovery
|
||||
if runtime_recovery.get("block"):
|
||||
findings.extend(
|
||||
_findings_from_reasons(
|
||||
"shared.runtime_recovery_contamination",
|
||||
runtime_recovery.get("reasons") or [],
|
||||
field="Runtime recovery",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state the manual daemon kill and the pending reconciler "
|
||||
"audit in the report; remove any clean-session claim"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||
report_text,
|
||||
@@ -1829,6 +1957,7 @@ def assess_final_report_validator(
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
"mutation_attempt_ledger": mutation_attempt_ledger,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
+2543
-170
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,78 @@ def build_gitea_issue_body(inc: NormalizedIncident) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def incident_recurred(
|
||||
existing: dict[str, Any], inc: NormalizedIncident
|
||||
) -> tuple[bool, str]:
|
||||
"""Did new provider events arrive since the existing link was last synced?
|
||||
|
||||
AC4 asks for a recurrence comment when events *continue*, so a scan that
|
||||
observes no new events must stay silent instead of re-posting the same
|
||||
state on every pass.
|
||||
"""
|
||||
old_count = existing.get("event_count")
|
||||
new_count = inc.event_count
|
||||
if (
|
||||
isinstance(old_count, int)
|
||||
and isinstance(new_count, int)
|
||||
and new_count > old_count
|
||||
):
|
||||
return True, f"event_count advanced {old_count} -> {new_count}"
|
||||
old_seen = str(existing.get("last_seen") or "").strip()
|
||||
new_seen = str(inc.last_seen or "").strip()
|
||||
if new_seen and new_seen != old_seen:
|
||||
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
|
||||
return False, "no new provider events since the last sync"
|
||||
|
||||
|
||||
def build_recurrence_comment_body(
|
||||
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
|
||||
) -> str:
|
||||
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
|
||||
|
||||
Uses the same redaction path as :func:`build_gitea_issue_body`; never
|
||||
carries tokens, raw paths, or session state.
|
||||
"""
|
||||
lines = [
|
||||
"## Observability incident recurrence (bridge #612)",
|
||||
"",
|
||||
"<!-- mcp-incident-bridge:recurrence:v1 -->",
|
||||
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||
"",
|
||||
f"Continued `{inc.provider}` events for this linked incident.",
|
||||
"",
|
||||
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
|
||||
]
|
||||
if inc.provider_short_id:
|
||||
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
|
||||
if inc.provider_permalink:
|
||||
lines.append(f"- **provider_url:** {inc.provider_permalink}")
|
||||
lines.extend(
|
||||
[
|
||||
f"- **event_count:** `{existing.get('event_count')}` -> "
|
||||
f"`{inc.event_count if inc.event_count is not None else ''}`",
|
||||
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||
f"- **environment:** `{inc.environment or ''}`",
|
||||
f"- **severity:** `{inc.severity or ''}`",
|
||||
f"- **culprit:** `{inc.culprit or ''}`",
|
||||
f"- **status:** `{inc.status}`",
|
||||
f"- **recurrence_basis:** `{reason}`",
|
||||
"",
|
||||
"### Latest summary",
|
||||
"",
|
||||
redact_text(inc.summary) or "(no summary)",
|
||||
"",
|
||||
"### Canonical next action",
|
||||
"",
|
||||
"Author: this incident is still firing — investigate under the "
|
||||
"normal Gitea workflow. This comment records observability "
|
||||
"recurrence only and changes no workflow state.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
|
||||
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
||||
eg_org = str(existing.get("gitea_org") or "")
|
||||
@@ -514,6 +586,9 @@ def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | N
|
||||
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
||||
|
||||
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
|
||||
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
|
||||
|
||||
|
||||
def reconcile_incident(
|
||||
db: ControlPlaneDB | None,
|
||||
@@ -523,6 +598,7 @@ def reconcile_incident(
|
||||
mapping: ProjectMapping | None = None,
|
||||
apply: bool = False,
|
||||
create_issue_fn: CreateIssueFn | None = None,
|
||||
comment_issue_fn: CommentIssueFn | None = None,
|
||||
force_gitea_issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reconcile one observation into incident_links + optional Gitea issue.
|
||||
@@ -531,6 +607,11 @@ def reconcile_incident(
|
||||
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
||||
|
||||
When an existing link is reused and the provider reports *new* events,
|
||||
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
|
||||
Gitea issue (AC4). Dry runs never comment, and a missing
|
||||
``comment_issue_fn`` withholds the comment without failing the link.
|
||||
|
||||
Never creates control-plane ``work_items`` for raw incidents.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
@@ -549,6 +630,7 @@ def reconcile_incident(
|
||||
"gitea_issue": None,
|
||||
"action": None,
|
||||
"mapping": None,
|
||||
"recurrence_comment": None,
|
||||
"substrate": "control_plane_db.incident_links",
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
@@ -652,10 +734,14 @@ def reconcile_incident(
|
||||
# --- apply path ---
|
||||
issue_number: int | None = None
|
||||
created = False
|
||||
recurrence: tuple[bool, str] | None = None
|
||||
if existing:
|
||||
issue_number = int(existing["gitea_issue_number"])
|
||||
action = "updated_existing_link"
|
||||
outcome = OUTCOME_UPDATED
|
||||
# Compare against the pre-upsert link row: the upsert below overwrites
|
||||
# event_count/last_seen, which would erase the recurrence signal.
|
||||
recurrence = incident_recurred(existing, inc)
|
||||
elif force_gitea_issue_number is not None:
|
||||
issue_number = int(force_gitea_issue_number)
|
||||
action = "link_explicit_issue"
|
||||
@@ -729,6 +815,63 @@ def reconcile_incident(
|
||||
}
|
||||
return base
|
||||
|
||||
# AC4: continued provider events post a recurrence comment on the linked
|
||||
# Gitea issue. The durable incident_links row is already written above, so
|
||||
# a comment failure never rolls back or blocks the mapping — the next scan
|
||||
# retries while the link stays authoritative.
|
||||
if outcome == OUTCOME_UPDATED and recurrence is not None:
|
||||
recurred, why = recurrence
|
||||
if not recurred:
|
||||
base["recurrence_comment"] = {"posted": False, "reason": why}
|
||||
elif comment_issue_fn is None:
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
"no comment_issue_fn supplied; recurrence comment withheld "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
comment_res = comment_issue_fn(
|
||||
issue_number,
|
||||
build_recurrence_comment_body(inc, existing, reason=why),
|
||||
inc.gitea_org,
|
||||
inc.gitea_repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - never break the link write
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
f"recurrence comment failed: {redact_text(exc)} "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
posted = (
|
||||
bool(comment_res.get("success"))
|
||||
if isinstance(comment_res, dict)
|
||||
else bool(comment_res)
|
||||
)
|
||||
base["recurrence_comment"] = {
|
||||
"posted": posted,
|
||||
"recurrence_basis": why,
|
||||
"gitea_issue_number": issue_number,
|
||||
"comment_id": (
|
||||
comment_res.get("comment_id")
|
||||
if isinstance(comment_res, dict)
|
||||
else None
|
||||
),
|
||||
}
|
||||
if posted:
|
||||
base["gitea_mutated"] = True
|
||||
elif isinstance(comment_res, dict):
|
||||
base["recurrence_comment"]["reasons"] = [
|
||||
redact_text(r) for r in (comment_res.get("reasons") or [])
|
||||
]
|
||||
|
||||
base["success"] = True
|
||||
base["performed"] = True
|
||||
base["db_mutated"] = True
|
||||
|
||||
+413
-29
@@ -22,6 +22,45 @@ 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
|
||||
@@ -40,6 +79,22 @@ 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:
|
||||
@@ -87,6 +142,130 @@ def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
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,
|
||||
*,
|
||||
@@ -107,6 +286,10 @@ def assess_dead_session_lock_recovery(
|
||||
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.
|
||||
|
||||
@@ -218,31 +401,109 @@ def assess_dead_session_lock_recovery(
|
||||
)
|
||||
evidence["dirty_files"] = dirty_files
|
||||
|
||||
# ── Head agreement: local == remote == PR ───────────────────────────────
|
||||
# ── 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")
|
||||
if not remote_head:
|
||||
reasons.append(
|
||||
f"remote head for branch '{locked_branch}' could not be determined"
|
||||
)
|
||||
if local_head and remote_head and local_head != remote_head:
|
||||
reasons.append(
|
||||
f"local head {local_head} does not match remote branch head {remote_head}"
|
||||
)
|
||||
|
||||
# #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
|
||||
if local_head and pr_head != local_head:
|
||||
reasons.append(
|
||||
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||
f"{local_head}"
|
||||
)
|
||||
# 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)
|
||||
@@ -335,16 +596,34 @@ def assess_dead_session_lock_recovery(
|
||||
if reasons:
|
||||
return _result(REFUSED, False, reasons, evidence)
|
||||
|
||||
return _result(
|
||||
RECOVERY_SANCTIONED,
|
||||
True,
|
||||
[
|
||||
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"
|
||||
],
|
||||
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(
|
||||
@@ -374,10 +653,16 @@ def owning_pr_recovery_evidence(
|
||||
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 local
|
||||
and remote heads. Nothing here is caller-supplied: every field is copied
|
||||
from evidence the assessor built out of durable lock state plus live
|
||||
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
|
||||
@@ -391,13 +676,28 @@ def owning_pr_recovery_evidence(
|
||||
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 pr_head != local_head or pr_head != remote_head:
|
||||
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)
|
||||
@@ -410,6 +710,77 @@ def owning_pr_recovery_evidence(
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +789,13 @@ def build_recovery_record(
|
||||
*,
|
||||
recovered_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
|
||||
"""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,
|
||||
@@ -429,8 +806,15 @@ def build_recovery_record(
|
||||
"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"),
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Exact-owner renewal of an expired author issue lease (#760).
|
||||
|
||||
An author issue lease carries an absolute wall-clock expiry stamped once at
|
||||
lock time. The PID recorded alongside it is the long-lived MCP daemon, not the
|
||||
authoring task, so a lease that expires while its daemon is still up is the
|
||||
ordinary case for any author task that outlives the TTL — not an anomaly.
|
||||
|
||||
Before this module, that case was unreachable.
|
||||
``issue_lock_store.assess_same_issue_lease_conflict`` computed same-owner
|
||||
evidence and then returned on the expired branch before consulting it, and
|
||||
``assess_expired_lock_reclaim`` only permits takeover on a dead PID or a
|
||||
missing worktree. An exact owner whose daemon is alive and whose worktree is
|
||||
present satisfied neither, so its own lock became permanently unmodifiable
|
||||
through sanctioned tools.
|
||||
|
||||
This module is the pure evidence assessor for that one narrow case. It answers
|
||||
a single question: may *this* session renew a lease it can prove it already
|
||||
owns? It performs no mutation and no network I/O, and it never trusts a caller
|
||||
assertion — every field is compared against durable lock state or a live
|
||||
observation supplied by the caller and gathered server-side.
|
||||
|
||||
Deliberate boundaries:
|
||||
|
||||
* **Renewal is not takeover.** A refusal here never widens what
|
||||
``assess_expired_lock_reclaim`` already allows; foreign expired locks keep
|
||||
requiring a dead PID or missing worktree (#760 AC11), and a *live* foreign
|
||||
lease stays non-recoverable by construction because only an expired lease is
|
||||
ever a candidate (AC12).
|
||||
* **PID liveness is never authorization.** A live recorded PID proves the
|
||||
daemon is up, nothing more. It is recorded as evidence and is neither
|
||||
necessary nor sufficient for renewal (AC16).
|
||||
* **Absolute expiry is preserved.** Renewal issues a new absolute expiry from
|
||||
the moment of the write. It does not introduce sliding heartbeat renewal,
|
||||
lease generations as fencing tokens, or a shared cross-role lifecycle — that
|
||||
is #790's scope and is deliberately not implemented here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from issue_lock_store import AUTHOR_ISSUE_WORK_LEASE, is_lease_expired, is_process_alive
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
# Outcome values.
|
||||
RENEWAL_SANCTIONED = "RENEWAL_SANCTIONED"
|
||||
NO_CANDIDATE = "NO_CANDIDATE"
|
||||
REFUSED = "REFUSED"
|
||||
|
||||
# Durable fields a lock must carry before it can be considered at all.
|
||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
claimant = lock.get("claimant")
|
||||
if not isinstance(claimant, Mapping):
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||
|
||||
|
||||
def _lock_lease(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
lease = lock.get("work_lease")
|
||||
return dict(lease) if isinstance(lease, Mapping) else {}
|
||||
|
||||
|
||||
def _lock_operation_type(lock: Mapping[str, Any]) -> str:
|
||||
lease = _lock_lease(lock)
|
||||
return _text(lease.get("operation_type")) or AUTHOR_ISSUE_WORK_LEASE
|
||||
|
||||
|
||||
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||
pid = lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock.get("pid")
|
||||
return pid
|
||||
|
||||
|
||||
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
"""Names of durable fields that are missing or unusable."""
|
||||
missing: list[str] = []
|
||||
for field in REQUIRED_LOCK_FIELDS:
|
||||
if not _text(lock.get(field)):
|
||||
missing.append(field)
|
||||
pid = _recorded_pid(lock)
|
||||
if pid is None or _text(pid) == "":
|
||||
missing.append("session_pid/pid")
|
||||
else:
|
||||
try:
|
||||
if int(pid) <= 0:
|
||||
missing.append("session_pid/pid")
|
||||
except (TypeError, ValueError):
|
||||
missing.append("session_pid/pid")
|
||||
return missing
|
||||
|
||||
|
||||
def _competing_lock_reasons(
|
||||
competing_live_locks: Iterable[Mapping[str, Any]] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
) -> list[str]:
|
||||
"""Live locks that would contend with this renewal (#760 AC7).
|
||||
|
||||
A live lock on the *same* issue cannot coexist with this expired lease, so
|
||||
any live entry naming this issue, branch, or worktree belongs to somebody
|
||||
else and refuses the renewal.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
for entry in competing_live_locks or ():
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
entry_issue = entry.get("issue_number")
|
||||
entry_branch = _text(entry.get("branch_name"))
|
||||
entry_worktree = _text(entry.get("worktree_path"))
|
||||
if entry_issue == issue_number:
|
||||
reasons.append(
|
||||
f"a live lock already exists for issue #{issue_number} "
|
||||
f"(pid {entry.get('pid')}); renewal would contend with it"
|
||||
)
|
||||
continue
|
||||
if entry_branch and entry_branch == _text(branch_name):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds branch "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if entry_worktree and _same_realpath(entry_worktree, worktree_path):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds worktree "
|
||||
f"'{worktree_path}'"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def assess_exact_owner_lease_renewal(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
current_branch: str | None = None,
|
||||
porcelain_status: str = "",
|
||||
worktree_exists: bool = False,
|
||||
head_sha: str | None = None,
|
||||
remote_head_sha: str | None = None,
|
||||
pr_head_sha: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||
candidate_branches: Sequence[str] | None = None,
|
||||
current_pid: int | None = None,
|
||||
now: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether an expired lease may be renewed by its exact owner.
|
||||
|
||||
Returns a disposition dict; it never raises and never mutates. A refusal
|
||||
withholds permission, leaving every pre-existing guard to fail closed
|
||||
exactly as before — this assessment can only ever *add* permission.
|
||||
|
||||
``NO_CANDIDATE`` means the situation is not an exact-owner renewal at all
|
||||
(no lock, different issue, different operation, or an unexpired lease) and
|
||||
the caller should carry on with its normal path. ``REFUSED`` means it looked
|
||||
like one but the evidence did not hold, and ``reasons`` names exactly what
|
||||
was missing.
|
||||
"""
|
||||
evidence: dict[str, Any] = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"operation_type": operation_type,
|
||||
"identity": identity,
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
def _result(outcome: str, reasons: list[str], **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"renewal_sanctioned": outcome == RENEWAL_SANCTIONED,
|
||||
"is_candidate": outcome in (RENEWAL_SANCTIONED, REFUSED),
|
||||
"reasons": reasons,
|
||||
"evidence": {**evidence, **extra},
|
||||
}
|
||||
|
||||
if not isinstance(existing_lock, Mapping) or not existing_lock:
|
||||
return _result(NO_CANDIDATE, ["no existing lock to renew"])
|
||||
|
||||
if existing_lock.get("issue_number") != issue_number:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lock is for issue #{existing_lock.get('issue_number')}, "
|
||||
f"not #{issue_number}"
|
||||
],
|
||||
)
|
||||
|
||||
existing_operation = _lock_operation_type(existing_lock)
|
||||
if existing_operation != operation_type:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lease operation '{existing_operation}' is not "
|
||||
f"'{operation_type}'"
|
||||
],
|
||||
)
|
||||
|
||||
# Only an *expired* lease is ever a renewal candidate. An unexpired lease —
|
||||
# live, or stale by dead PID — is somebody else's problem: the first needs no
|
||||
# renewal, and the second is #753's dead-session recovery. This is also what
|
||||
# makes a live foreign lease non-recoverable here (#760 AC12).
|
||||
if not is_lease_expired(existing_lock, now=now):
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
["lease has not expired; renewal does not apply"],
|
||||
)
|
||||
|
||||
malformed = _malformed_reasons(existing_lock)
|
||||
if malformed:
|
||||
return _result(
|
||||
REFUSED,
|
||||
["durable lock is missing or has unusable fields: " + ", ".join(malformed)],
|
||||
)
|
||||
|
||||
lease = _lock_lease(existing_lock)
|
||||
claimant = _lock_claimant(existing_lock)
|
||||
recorded_pid = _recorded_pid(existing_lock)
|
||||
prior_expires_at = _text(lease.get("expires_at"))
|
||||
|
||||
# #760 AC16: recorded purely as evidence. A live daemon PID is neither
|
||||
# necessary nor sufficient for renewal, and nothing below branches on it.
|
||||
recorded_pid_alive = is_process_alive(recorded_pid)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"prior_pid": recorded_pid,
|
||||
"prior_pid_alive": recorded_pid_alive,
|
||||
"prior_expires_at": prior_expires_at,
|
||||
"replacement_pid": current_pid,
|
||||
"recorded_claimant": claimant,
|
||||
"head_sha": head_sha,
|
||||
"remote_head_sha": remote_head_sha,
|
||||
"pr_head_sha": pr_head_sha,
|
||||
"pr_number": pr_number,
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
# ── AC3: exact ownership identity ──
|
||||
if _text(existing_lock.get("remote")) != _text(remote):
|
||||
reasons.append(
|
||||
f"recorded remote '{existing_lock.get('remote')}' does not match "
|
||||
f"'{remote}'"
|
||||
)
|
||||
if _text(existing_lock.get("org")) != _text(org):
|
||||
reasons.append(
|
||||
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
|
||||
)
|
||||
if _text(existing_lock.get("repo")) != _text(repo):
|
||||
reasons.append(
|
||||
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
|
||||
)
|
||||
if _text(existing_lock.get("branch_name")) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path):
|
||||
reasons.append(
|
||||
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
|
||||
f"match '{worktree_path}'"
|
||||
)
|
||||
|
||||
recorded_identity = _text(claimant.get("username"))
|
||||
recorded_profile = _text(claimant.get("profile"))
|
||||
if not recorded_identity or not recorded_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record both a claimant username and profile"
|
||||
)
|
||||
if recorded_identity and recorded_identity != _text(identity):
|
||||
reasons.append(
|
||||
f"recorded claimant '{recorded_identity}' does not match active "
|
||||
f"identity '{_text(identity) or 'unknown'}'"
|
||||
)
|
||||
if recorded_profile and recorded_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"recorded profile '{recorded_profile}' does not match active profile "
|
||||
f"'{_text(profile) or 'unknown'}'"
|
||||
)
|
||||
|
||||
# ── AC4: the registered worktree still exists, is on the branch, and is clean ──
|
||||
if not worktree_exists:
|
||||
reasons.append(f"declared worktree '{worktree_path}' does not exist")
|
||||
if _text(current_branch) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||
if dirty:
|
||||
reasons.append(
|
||||
"worktree has uncommitted tracked changes: " + ", ".join(sorted(dirty))
|
||||
)
|
||||
|
||||
# ── AC5/AC6: published heads must agree ──
|
||||
if not _text(head_sha):
|
||||
reasons.append("local head could not be observed")
|
||||
if not _text(remote_head_sha):
|
||||
reasons.append(
|
||||
"remote branch head could not be observed; an unpublished branch "
|
||||
"cannot prove exact-owner renewal"
|
||||
)
|
||||
if _text(head_sha) and _text(remote_head_sha) and head_sha != remote_head_sha:
|
||||
reasons.append(
|
||||
f"local head {head_sha} does not equal remote head {remote_head_sha}"
|
||||
)
|
||||
if pr_number is not None:
|
||||
if not _text(pr_head_sha):
|
||||
reasons.append(f"owning PR #{pr_number} head could not be observed")
|
||||
elif _text(head_sha) and pr_head_sha != head_sha:
|
||||
reasons.append(
|
||||
f"owning PR #{pr_number} head {pr_head_sha} does not equal local "
|
||||
f"head {head_sha}"
|
||||
)
|
||||
|
||||
# ── AC7: nothing else claims this work ──
|
||||
reasons.extend(
|
||||
_competing_lock_reasons(
|
||||
competing_live_locks,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=worktree_path,
|
||||
)
|
||||
)
|
||||
other_branches = [
|
||||
name
|
||||
for name in (candidate_branches or ())
|
||||
if _text(name) and _text(name) != _text(branch_name)
|
||||
]
|
||||
if other_branches:
|
||||
reasons.append(
|
||||
"other branches already carry this issue marker: "
|
||||
+ ", ".join(sorted(other_branches))
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return _result(REFUSED, reasons, **extra)
|
||||
|
||||
return _result(
|
||||
RENEWAL_SANCTIONED,
|
||||
[
|
||||
f"exact owner '{recorded_identity}' ({recorded_profile}) proved "
|
||||
f"ownership of issue #{issue_number} on branch '{branch_name}' from "
|
||||
f"worktree '{worktree_path}'; local, remote"
|
||||
+ (f", and PR #{pr_number}" if pr_number is not None else "")
|
||||
+ f" heads all equal {head_sha}; lease expired at "
|
||||
f"{prior_expires_at or 'unknown'}"
|
||||
],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def owning_pr_renewal_evidence(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Server-derived proof of the open PR a sanctioned renewal already owns.
|
||||
|
||||
The mirror of ``issue_lock_recovery.owning_pr_recovery_evidence`` (#755) for
|
||||
the renewal disposition. An exact-owner renewal of a published branch is, by
|
||||
construction, renewal of work that already has an open PR — so the
|
||||
duplicate-work gate's linked-open-PR blocker would otherwise discard every
|
||||
sanctioned renewal, exactly as it once discarded every sanctioned recovery.
|
||||
|
||||
Returns ``None`` unless renewal was actually granted and the evidence names
|
||||
one owning PR whose head agrees with both the local and remote heads the
|
||||
assessor accepted. Nothing is caller-supplied: every field is copied from
|
||||
evidence built out of durable lock state plus live git/Gitea observation.
|
||||
|
||||
Renewal has no descendant case — it requires the local, remote, and PR heads
|
||||
to be equal — so there is only one head to report.
|
||||
"""
|
||||
if not isinstance(assessment, Mapping):
|
||||
return None
|
||||
if assessment.get("outcome") != RENEWAL_SANCTIONED:
|
||||
return None
|
||||
if not assessment.get("renewal_sanctioned"):
|
||||
return None
|
||||
|
||||
evidence = assessment.get("evidence") or {}
|
||||
branch_name = _text(evidence.get("branch_name"))
|
||||
pr_head = _text(evidence.get("pr_head_sha"))
|
||||
local_head = _text(evidence.get("head_sha"))
|
||||
remote_head = _text(evidence.get("remote_head_sha"))
|
||||
raw_pr_number = evidence.get("pr_number")
|
||||
|
||||
if raw_pr_number is None or not branch_name or not pr_head:
|
||||
return None
|
||||
# The assessor already required these to agree. Re-check, so a truncated or
|
||||
# hand-built evidence map can never authorize an exemption.
|
||||
if pr_head != local_head or pr_head != remote_head:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(evidence.get("issue_number"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": pr_head,
|
||||
"accepted_head": pr_head,
|
||||
"head_relation": "equal",
|
||||
}
|
||||
|
||||
|
||||
def build_renewal_record(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
*,
|
||||
renewed_at: str,
|
||||
new_expires_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable audit record for a sanctioned renewal (#760 AC9).
|
||||
|
||||
Records both sides of the transition — prior PID and expiry, replacement PID
|
||||
and new expiry — so a renewed lock is never mistakable for an original
|
||||
claim, and so the evidence the waiver was granted on stays inspectable.
|
||||
"""
|
||||
data = dict(assessment or {})
|
||||
evidence = dict(data.get("evidence") or {})
|
||||
recorded_claimant = dict(evidence.get("recorded_claimant") or {})
|
||||
return {
|
||||
"renewed": bool(data.get("renewal_sanctioned")),
|
||||
"renewed_at": renewed_at,
|
||||
"prior_pid": evidence.get("prior_pid"),
|
||||
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||
"prior_expires_at": evidence.get("prior_expires_at"),
|
||||
"replacement_pid": evidence.get("replacement_pid"),
|
||||
"new_expires_at": new_expires_at,
|
||||
"identity": recorded_claimant.get("username"),
|
||||
"profile": recorded_claimant.get("profile"),
|
||||
"branch_name": evidence.get("branch_name"),
|
||||
"worktree_path": evidence.get("worktree_path"),
|
||||
"head_sha": evidence.get("head_sha"),
|
||||
"remote_head_sha": evidence.get("remote_head_sha"),
|
||||
"pr_head_sha": evidence.get("pr_head_sha"),
|
||||
"pr_number": evidence.get("pr_number"),
|
||||
"reason": "expired lease renewed by its exact recorded owner",
|
||||
"proof": list(data.get("reasons") or []),
|
||||
}
|
||||
|
||||
|
||||
def format_renewal_refusal(assessment: Mapping[str, Any] | None) -> str:
|
||||
"""One-line refusal summary for a blocked caller."""
|
||||
data = dict(assessment or {})
|
||||
reasons = list(data.get("reasons") or [])
|
||||
if not reasons:
|
||||
return "exact-owner lease renewal was not available (no evidence recorded)"
|
||||
return "exact-owner lease renewal refused: " + "; ".join(reasons)
|
||||
+68
-3
@@ -148,8 +148,38 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
def lock_generation(lock: dict[str, Any] | None) -> int:
|
||||
"""Monotonic write counter for a durable lock record (#772 AC5).
|
||||
|
||||
Absent or unusable values read as ``0`` so a lock written before generations
|
||||
existed still participates in compare-and-swap: its first recovery expects
|
||||
``0`` and writes ``1``.
|
||||
"""
|
||||
if not isinstance(lock, dict):
|
||||
return 0
|
||||
try:
|
||||
return int(lock.get("lock_generation") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bind_session_lock(
|
||||
lock_data: dict[str, Any],
|
||||
lock_dir: str | None = None,
|
||||
*,
|
||||
expected_generation: int | None = None,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session.
|
||||
|
||||
``expected_generation`` turns the write into a compare-and-swap (#772 AC5).
|
||||
Recovery decides it may take over a claim by reading the durable lock, but
|
||||
that read and this write are separate steps; without a CAS two replacement
|
||||
sessions can both observe the same dead owner, both pass assessment, and
|
||||
both write — the second silently clobbering the first. Passing the
|
||||
generation observed at assessment time makes exactly one of them win: the
|
||||
loser's expectation no longer matches and it fails closed.
|
||||
"""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
@@ -191,9 +221,24 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
# #772 AC5: compare-and-swap inside the same critical section that
|
||||
# already serializes writers, so the check and the write cannot be
|
||||
# separated by another session's successful recovery.
|
||||
current_generation = lock_generation(existing)
|
||||
if (
|
||||
expected_generation is not None
|
||||
and current_generation != expected_generation
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock generation changed: expected "
|
||||
f"{expected_generation}, found {current_generation}; another "
|
||||
"session already recovered or replaced this claim (fail closed)"
|
||||
)
|
||||
record["lock_generation"] = current_generation + 1
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
@@ -440,9 +485,19 @@ def assess_same_issue_lease_conflict(
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
renewal_sanctioned: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition.
|
||||
|
||||
``renewal_sanctioned`` is set only when
|
||||
``issue_lock_renewal.assess_exact_owner_lease_renewal`` has already proven,
|
||||
from the durable lock plus live server-side observation, that this session
|
||||
is the exact recorded owner of an *expired* lease (#760). It is never a
|
||||
caller-supplied parameter of any MCP tool (#760 AC14): the server computes
|
||||
it and passes it down. Left False, every pre-existing disposition is
|
||||
unchanged.
|
||||
"""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
@@ -463,6 +518,16 @@ def assess_same_issue_lease_conflict(
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
# #760 AC1/AC2: exact-owner renewal is a different disposition from
|
||||
# foreign takeover and is evaluated first. Before this, both branches
|
||||
# below returned unconditionally, so the same_owner allowance further
|
||||
# down was unreachable for every expired lease — an owner could never
|
||||
# renew its own lock once the wall clock passed, no matter how complete
|
||||
# its ownership evidence. Requires BOTH the locally recomputed
|
||||
# same_owner match and the server-proven renewal waiver; either alone is
|
||||
# insufficient.
|
||||
if same_owner and renewal_sanctioned:
|
||||
return None
|
||||
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
|
||||
if reclaim.get("reclaim_allowed"):
|
||||
# #601: expired + dead pid / missing worktree may be reclaimed
|
||||
|
||||
+218
-4
@@ -20,16 +20,208 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
def resolve_author_worktree_path(
|
||||
explicit: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
session_lock_worktree: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the author worktree path for lock/PR gates."""
|
||||
"""Resolve the author worktree path for lock/PR gates.
|
||||
|
||||
#618: prefer explicit path, then env, then the active issue lock worktree.
|
||||
Does not invent a branches/ worktree. Falling back to *project_root* is
|
||||
retained only for lock-time bootstrap when the process itself is already
|
||||
under branches/ or no binding exists yet (callers still fail closed via
|
||||
preflight / durable resolution before mutation).
|
||||
"""
|
||||
path = (explicit or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
|
||||
if not path:
|
||||
path = (session_lock_worktree or "").strip()
|
||||
if not path:
|
||||
path = project_root
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_head_ancestry(
|
||||
worktree_path: str,
|
||||
*,
|
||||
ancestor_sha: str | None,
|
||||
descendant_sha: str | None,
|
||||
) -> dict:
|
||||
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
|
||||
|
||||
Server-side git observation for dead-session lock recovery. The recovering
|
||||
author's only reachable clean-worktree state is one commit *ahead* of the
|
||||
head recorded at lock time, so recovery needs to know whether that commit
|
||||
extends the recorded head or replaces it.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing here is supplied
|
||||
by, or reachable from, an MCP caller (#768 AC6).
|
||||
|
||||
``ancestor_present`` proves the recorded head is still reachable, which is
|
||||
what separates an honest fast-forward from a rewritten or force-moved
|
||||
history: a rewritten recorded head leaves the object graph and the probe
|
||||
fails closed.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
ancestor = (ancestor_sha or "").strip()
|
||||
descendant = (descendant_sha or "").strip()
|
||||
result: dict = {
|
||||
"ancestor_sha": ancestor or None,
|
||||
"descendant_sha": descendant or None,
|
||||
"probe_ok": False,
|
||||
"ancestor_present": False,
|
||||
"descendant_present": False,
|
||||
"is_ancestor": False,
|
||||
"is_strict_descendant": False,
|
||||
"proof": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not ancestor or not descendant:
|
||||
result["reasons"].append(
|
||||
"ancestry probe requires a worktree path and both commit SHAs"
|
||||
)
|
||||
return result
|
||||
|
||||
def _present(sha: str) -> bool:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return res.returncode == 0
|
||||
|
||||
try:
|
||||
result["ancestor_present"] = _present(ancestor)
|
||||
result["descendant_present"] = _present(descendant)
|
||||
except OSError as exc: # git unavailable — fail closed, never assume
|
||||
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||
return result
|
||||
|
||||
if not result["ancestor_present"]:
|
||||
result["reasons"].append(
|
||||
f"recorded head {ancestor} is not reachable in '{path}'; history may "
|
||||
"have been rewritten or force-moved"
|
||||
)
|
||||
if not result["descendant_present"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} is not reachable in '{path}'"
|
||||
)
|
||||
if not (result["ancestor_present"] and result["descendant_present"]):
|
||||
return result
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
|
||||
if probe.returncode not in (0, 1):
|
||||
result["reasons"].append(
|
||||
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
|
||||
)
|
||||
return result
|
||||
|
||||
result["probe_ok"] = True
|
||||
result["is_ancestor"] = probe.returncode == 0
|
||||
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
|
||||
result["proof"] = (
|
||||
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
|
||||
f"-> exit {probe.returncode}"
|
||||
)
|
||||
if not result["is_ancestor"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} does not descend from recorded head {ancestor}"
|
||||
)
|
||||
elif not result["is_strict_descendant"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} equals the recorded head; no descendant "
|
||||
"recovery is involved"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_recorded_base(
|
||||
worktree_path: str,
|
||||
*,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Observe the base commit an unpublished claim was branched from (#772).
|
||||
|
||||
A published claim records its base implicitly: the remote branch head is the
|
||||
thing recovery measures against. An unpublished claim has no remote ref, so
|
||||
the base must be observed here, server-side, as the merge-base between the
|
||||
worktree HEAD and the base branch it was cut from.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing is supplied by, or
|
||||
reachable from, an MCP caller, so a caller cannot nominate a base that would
|
||||
make unrelated history look like a descendant (#772 AC1/AC4).
|
||||
|
||||
A HEAD with no common ancestor in any base branch yields ``probe_ok`` with no
|
||||
``base_sha``: unrelated history is reported as exactly that, never as a base.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
head = (head_sha or "").strip()
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
candidates = [*extra_bases, *sorted(bases)]
|
||||
result: dict = {
|
||||
"base_branch": None,
|
||||
"base_sha": None,
|
||||
"head_sha": head or None,
|
||||
"probe_ok": False,
|
||||
"candidates": candidates,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not head:
|
||||
result["reasons"].append(
|
||||
"recorded-base probe requires a worktree path and a HEAD sha"
|
||||
)
|
||||
return result
|
||||
|
||||
probed_any = False
|
||||
for candidate in candidates:
|
||||
name = (candidate or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", name, head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode not in (0, 1):
|
||||
# 0 = merge base found, 1 = no common ancestor. Anything else is a
|
||||
# failed probe (missing ref, broken repo) — try the next candidate.
|
||||
continue
|
||||
probed_any = True
|
||||
merge_base = (probe.stdout or "").strip()
|
||||
if probe.returncode == 0 and merge_base:
|
||||
result["base_branch"] = name
|
||||
result["base_sha"] = merge_base
|
||||
result["probe_ok"] = True
|
||||
return result
|
||||
|
||||
result["probe_ok"] = probed_any
|
||||
if probed_any:
|
||||
result["reasons"].append(
|
||||
f"HEAD {head} shares no common ancestor with any of "
|
||||
f"{_base_list(bases)}; history is unrelated to this repository's base"
|
||||
)
|
||||
else:
|
||||
result["reasons"].append(
|
||||
f"recorded-base probe could not run against any of {_base_list(bases)} "
|
||||
f"in '{path}'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
@@ -93,6 +285,7 @@ def assess_issue_lock_worktree(
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree.
|
||||
|
||||
@@ -104,6 +297,19 @@ def assess_issue_lock_worktree(
|
||||
by construction and could never satisfy it. Every other precondition —
|
||||
notably worktree cleanliness — still applies unchanged, and brand-new issue
|
||||
claims keep the full base-equivalence requirement.
|
||||
|
||||
``renewal_sanctioned`` waives base-equivalence on exactly the same grounds
|
||||
for the other proven-ownership case (#760): ``issue_lock_renewal`` has shown
|
||||
that an *expired* lease is being renewed by its exact recorded owner — same
|
||||
remote, org, repo, issue, operation, branch, realpath-normalized worktree,
|
||||
claimant username and profile — with the local head matching the remote head
|
||||
and any owning PR head. Such a branch carries committed work for the same
|
||||
reason a recovered one does, so it can never be base-equivalent either.
|
||||
|
||||
Both waivers relax this one requirement and nothing else. Neither is
|
||||
caller-supplied: each is computed server-side from durable lock state plus
|
||||
live observation. With both False every precondition applies exactly as
|
||||
before.
|
||||
"""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
@@ -122,9 +328,12 @@ def assess_issue_lock_worktree(
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if recovery_sanctioned:
|
||||
if recovery_sanctioned or renewal_sanctioned:
|
||||
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||
# against the durable lock record instead (#753).
|
||||
# against the durable lock record instead — by dead-session recovery
|
||||
# (#753) or by exact-owner renewal of an expired lease (#760). Every
|
||||
# other precondition above and below still applies; cleanliness in
|
||||
# particular is checked before this branch and is never waived.
|
||||
pass
|
||||
elif base_equivalent is False:
|
||||
reasons.append(
|
||||
@@ -155,6 +364,7 @@ def assess_issue_lock_worktree(
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,6 +424,7 @@ def _assessment(
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
@@ -226,7 +437,10 @@ def _assessment(
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
"recovery_sanctioned": recovery_sanctioned,
|
||||
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||
"renewal_sanctioned": renewal_sanctioned,
|
||||
# Either proven-ownership waiver relaxes base-equivalence; the two are
|
||||
# reported separately so an audit can tell which one applied.
|
||||
"base_equivalence_waived": bool(recovery_sanctioned or renewal_sanctioned),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,18 +122,30 @@ def _assess_owning_pr_exemption(
|
||||
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
||||
)
|
||||
return False, notes
|
||||
if not token_head or not only_sha or only_sha != token_head:
|
||||
# #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'} "
|
||||
"(no owning-PR exemption)"
|
||||
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 {token_head}); not duplicate work"
|
||||
f"head {only_sha}); not duplicate work"
|
||||
]
|
||||
|
||||
|
||||
|
||||
+213
-17
@@ -24,12 +24,50 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Live-remote head cache: the parity gate runs on every mutation and every
|
||||
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
|
||||
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
|
||||
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
|
||||
_REMOTE_HEAD_TTL = 60.0
|
||||
|
||||
# When True, ``read_remote_master_head`` never performs ``git ls-remote`` unless
|
||||
# ``GITEA_TEST_LIVE_REMOTE_HEAD`` is set. Conftest enables this suite-wide so
|
||||
# feature worktrees (whose HEAD differs from live master) cannot flip legacy
|
||||
# runtime-context assertions to live_stale, and so unit tests never depend on
|
||||
# a live network (PR #788 F1/F2 / issue #610). Module-level (not env-only) so
|
||||
# ``patch.dict(os.environ, …, clear=True)`` cannot re-enable the probe.
|
||||
_HERMETIC_TEST_MODE: bool = False
|
||||
|
||||
|
||||
def _clear_remote_head_cache() -> None:
|
||||
"""Reset the live-remote head cache (test isolation / forced refresh)."""
|
||||
_REMOTE_HEAD_CACHE.clear()
|
||||
|
||||
|
||||
def set_hermetic_test_mode(enabled: bool) -> None:
|
||||
"""Enable or disable suite-wide hermetic live-remote reads (tests only)."""
|
||||
global _HERMETIC_TEST_MODE
|
||||
_HERMETIC_TEST_MODE = bool(enabled)
|
||||
_clear_remote_head_cache()
|
||||
|
||||
|
||||
def hermetic_test_mode() -> bool:
|
||||
"""Return whether hermetic live-remote reads are active."""
|
||||
return bool(_HERMETIC_TEST_MODE)
|
||||
|
||||
|
||||
# Environment escape hatches (ops + tests):
|
||||
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
|
||||
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
|
||||
# GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE -> opt a single test into a real ls-remote
|
||||
# even when hermetic mode is on (rare; prefer ENV_TEST_LIVE_REMOTE_HEAD).
|
||||
ENV_TEST_ALLOW_LIVE_REMOTE_PROBE = "GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
@@ -58,6 +96,75 @@ def read_git_head(root: str) -> str | None:
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def read_remote_master_head(
|
||||
root: str,
|
||||
remote: str = "origin",
|
||||
branch: str = "master",
|
||||
ttl: float = _REMOTE_HEAD_TTL,
|
||||
) -> str | None:
|
||||
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
|
||||
|
||||
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
|
||||
a daemon that is behind the live remote master apart from one whose local
|
||||
checkout simply hasn't been pulled. ``None`` means the live head could not
|
||||
be resolved (offline, no such remote, git unavailable, error) -- callers
|
||||
must treat unknown live state as *not mutation-safe* while never blocking
|
||||
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
|
||||
precedence so the wiring can be exercised deterministically and offline.
|
||||
|
||||
The result is cached for *ttl* seconds per (root, remote, branch) so the
|
||||
gate does not run a network probe on every mutation/read (``ttl=0`` forces
|
||||
a live probe). Both hits and ``None`` misses are cached to bound offline
|
||||
latency; the env override bypasses the cache and the subprocess entirely.
|
||||
|
||||
Under suite hermetic mode (``set_hermetic_test_mode(True)``, set by
|
||||
conftest) a missing override returns ``None`` without network I/O so
|
||||
feature-worktree test runs cannot observe live_stale against real master
|
||||
(PR #788 F1) and unit tests stay offline (F2). Opt out with an explicit
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` pin or ``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if _HERMETIC_TEST_MODE and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
# Hermetic default: live head unknown. live_stale stays False;
|
||||
# mutation_safe is False when live is unknown (documented #610 note).
|
||||
return None
|
||||
# Defense in depth: even without the module flag, never probe while pytest
|
||||
# is running unless the test opted into a real probe or set an override.
|
||||
if (os.environ.get("PYTEST_CURRENT_TEST") or "").strip() and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
return None
|
||||
if not root:
|
||||
return None
|
||||
key = (root, remote, branch)
|
||||
now = time.monotonic()
|
||||
if ttl > 0:
|
||||
cached = _REMOTE_HEAD_CACHE.get(key)
|
||||
if cached is not None and (now - cached[0]) < ttl:
|
||||
return cached[1]
|
||||
sha: str | None = None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
lines = (res.stdout or "").strip().splitlines()
|
||||
if lines:
|
||||
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
|
||||
except Exception:
|
||||
sha = None
|
||||
_REMOTE_HEAD_CACHE[key] = (now, sha)
|
||||
return sha
|
||||
|
||||
|
||||
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||
"""Capture the process source-tree baseline once at server startup.
|
||||
|
||||
@@ -72,18 +179,38 @@ def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
def assess_master_parity(
|
||||
startup: dict | None,
|
||||
current_head: str | None,
|
||||
live_remote_head: str | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
Pure: all HEADs are supplied by the caller. Returns a structured result:
|
||||
|
||||
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``determinable`` -- whether both local HEADs were known well enough to
|
||||
compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
|
||||
#610 adds live-remote awareness so a daemon that is stale relative to the
|
||||
*live* remote master cannot report a mutation-safe result even when the
|
||||
local checkout HEAD still matches the daemon's startup commit:
|
||||
|
||||
- ``daemon_start_head`` -- the commit the running process started at
|
||||
(alias of ``startup_head``, named for clarity in reports).
|
||||
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
|
||||
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
|
||||
could not be fetched.
|
||||
- ``live_known`` -- whether the live remote target was resolved.
|
||||
- ``live_stale`` -- the live remote master has advanced past the running
|
||||
process (daemon is behind live master) even if local parity is green.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
|
||||
target all agree; the only state in which a mutation may rely on parity.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
@@ -91,32 +218,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, startup_head, current_head, reasons)
|
||||
local_in_parity = startup_head == current_head
|
||||
local_stale = not local_in_parity
|
||||
if local_stale:
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the "
|
||||
f"workspace master is now {_short(current_head)}; restart the "
|
||||
f"server to load the current capability gates")
|
||||
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||
f"master is now {_short(current_head)}; restart the server to load the "
|
||||
f"current capability gates")
|
||||
return _result(False, True, True, startup_head, current_head, reasons)
|
||||
live_known = live_remote_head is not None
|
||||
live_stale = live_known and live_remote_head != startup_head
|
||||
if live_stale:
|
||||
reasons.append(
|
||||
f"live remote master is {_short(live_remote_head)} but the MCP "
|
||||
f"server started at {_short(startup_head)}; the daemon is stale "
|
||||
f"relative to live master -- restart/reconnect before mutating")
|
||||
|
||||
return _result(
|
||||
local_in_parity, local_stale, True, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons):
|
||||
live_known = live_remote_head is not None
|
||||
mutation_safe = (
|
||||
determinable and in_parity and live_known and not live_stale)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"restart_required": stale or live_stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
# #610 distinguished signals:
|
||||
"daemon_start_head": startup_head,
|
||||
"local_head": current_head,
|
||||
"live_remote_head": live_remote_head,
|
||||
"live_known": live_known,
|
||||
"live_stale": live_stale,
|
||||
"mutation_safe": mutation_safe,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
@@ -130,11 +281,13 @@ def parity_block_reasons(assessment: dict) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||
|
||||
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||
reasons; only a definitively stale server blocks.
|
||||
reasons. A definitively stale server blocks, and (#610) a daemon that is
|
||||
stale relative to the *live* remote master blocks even when the local
|
||||
checkout HEAD still matches the daemon's startup commit.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
if assessment.get("stale") or assessment.get("live_stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
@@ -147,6 +300,10 @@ def parity_report(assessment: dict) -> dict:
|
||||
"restart_required": True,
|
||||
"startup_head": assessment.get("startup_head"),
|
||||
"current_head": assessment.get("current_head"),
|
||||
# #610: name the live remote target so the report distinguishes a
|
||||
# local-code stale from a daemon-behind-live-master stale.
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"live_stale": bool(assessment.get("live_stale")),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"recovery": [
|
||||
"The running MCP server is executing code older than the current "
|
||||
@@ -157,6 +314,45 @@ def parity_report(assessment: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def parity_resolver_disagreement(
|
||||
assessment: dict,
|
||||
resolver_restart_required: bool,
|
||||
) -> dict | None:
|
||||
"""Typed blocker when the resolver requires restart but parity looks green.
|
||||
|
||||
The capability resolver (``gitea_resolve_task_capability``) detects stale
|
||||
runtime authoritatively for mutation safety (#610). When it requires a
|
||||
restart, local-only parity must never override it: this returns a typed,
|
||||
fail-closed blocker that names the resolver as authoritative. Returns
|
||||
``None`` when the resolver does not require a restart.
|
||||
"""
|
||||
if not resolver_restart_required:
|
||||
return None
|
||||
parity_optimistic = bool(assessment.get("in_parity")) and not (
|
||||
assessment.get("stale") or assessment.get("live_stale"))
|
||||
return {
|
||||
"kind": "parity_resolver_disagreement",
|
||||
"restart_required": True,
|
||||
"resolver_authoritative": True,
|
||||
"parity_optimistic": parity_optimistic,
|
||||
"daemon_start_head": assessment.get("daemon_start_head"),
|
||||
"local_head": assessment.get("local_head"),
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"reasons": [
|
||||
"The capability resolver requires a restart/reconnect (stale "
|
||||
"runtime) but master-parity reported local code as in-parity. "
|
||||
"The resolver is authoritative for mutation safety; do not mutate "
|
||||
"on local parity alone. Restart/reconnect the Gitea MCP server "
|
||||
"and re-verify before mutating.",
|
||||
],
|
||||
"recovery": [
|
||||
"Trust the resolver: treat this session as stale.",
|
||||
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
|
||||
"current master and live target state, then re-run preflight.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_parity(assessment: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
if assessment.get("stale"):
|
||||
|
||||
+43
-15
@@ -19,6 +19,32 @@ print_banner() {
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_workflow_dashboard_help() {
|
||||
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
|
||||
printf 'Read-only operational view (#605). Does NOT assign work.\n'
|
||||
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
|
||||
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
|
||||
printf ' gitea_workflow_dashboard(\n'
|
||||
printf ' remote=\"prgs\",\n'
|
||||
printf ' org=\"Scaled-Tech-Consulting\",\n'
|
||||
printf ' repo=\"Gitea-Tools\",\n'
|
||||
printf ' )\n\n'
|
||||
printf 'Returns machine-readable sections:\n'
|
||||
printf ' - open_pr_queue / open_issue_queue\n'
|
||||
printf ' - active_leases_by_role / stale_or_expired_leases\n'
|
||||
printf ' - terminal_review_lock\n'
|
||||
printf ' - blocked_items (never presented as safe)\n'
|
||||
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
|
||||
printf ' - discussion_issues / controller_needed\n'
|
||||
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
|
||||
printf ' - human_summary (copy-friendly multi-line text)\n\n'
|
||||
printf 'Safety:\n'
|
||||
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
|
||||
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
|
||||
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
@@ -241,25 +267,27 @@ main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
|
||||
printf ' 3) Author workflow prompts\n'
|
||||
printf ' 4) Reviewer workflow prompts\n'
|
||||
printf ' 5) Merger workflow prompts\n'
|
||||
printf ' 6) Reconciler workflow prompts\n'
|
||||
printf ' 7) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 8) Proxmox deployment menu placeholder\n'
|
||||
printf ' 9) Create Proxmox LXC placeholder\n'
|
||||
printf ' t) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
2) show_workflow_dashboard_help ;;
|
||||
3) show_author_prompts ;;
|
||||
4) show_reviewer_prompts ;;
|
||||
5) show_merger_prompts ;;
|
||||
6) show_reconciler_prompts ;;
|
||||
7) show_onboarding_prompt ;;
|
||||
8|9) show_proxmox_placeholder ;;
|
||||
t|T|tests) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
|
||||
@@ -36,6 +36,11 @@ KIND_REVIEW_DRAFT = "review_draft"
|
||||
# other session proofs; a contaminated session fails closed on gated mutations
|
||||
# until a reconciler audits and clears it.
|
||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||
# Durable marker set when a worker session manually kills MCP daemon processes
|
||||
# instead of using a sanctioned reconnect/restart path (#630). Same shape and
|
||||
# same reconciler-only clear as the #671 marker above; kept as its own kind so
|
||||
# an audit can tell the two contamination classes apart.
|
||||
KIND_RUNTIME_RECOVERY_CONTAMINATION = "runtime_recovery_contamination"
|
||||
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
||||
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||
@@ -71,6 +76,10 @@ RECOVERY_CRITICAL_KINDS = frozenset(
|
||||
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||
KIND_REVIEWER_SESSION_LEASE,
|
||||
KIND_STALE_BINDING_RECOVERY,
|
||||
# #630: contamination must not expire into cleanliness. A TTL-bound
|
||||
# marker would let a contaminated session self-clear by waiting, which
|
||||
# defeats the reconciler-only clear the gate depends on.
|
||||
KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
durable_author_result: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||
|
||||
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||
are demoted (#702): a binding to a deleted worktree can never name a
|
||||
valid task workspace, so resolution falls through to the next candidate.
|
||||
Explicit arguments are never demoted — a caller-declared path must fail
|
||||
loudly downstream rather than silently rebind. Demotion notes are
|
||||
appended to *demotions* when provided. Runtime-context and mutation
|
||||
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||
can never name a valid task workspace, so resolution falls through to the
|
||||
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||
path must fail loudly downstream rather than silently rebind. Demotion
|
||||
notes are appended to *demotions* when provided.
|
||||
|
||||
Author role (#618): never demotes a missing configured binding to the
|
||||
control checkout. When *verify_paths* is true, resolution goes through
|
||||
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
||||
mutations either use an explicit validated worktree, derive from the
|
||||
active author issue lock, or fail closed. Runtime-context and mutation
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
@@ -74,6 +82,32 @@ def resolve_namespace_workspace(
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
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 (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
@@ -83,6 +117,11 @@ def resolve_namespace_workspace(
|
||||
f"{role_env_key} environment variable", True),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree", False),
|
||||
# Author lock derivation is handled by the durable path above when
|
||||
# verify_paths is true; when verify_paths is false, surface the lock
|
||||
# path as a non-demoted candidate so tooling can inspect it.
|
||||
(session_lock_worktree if role == "author" else None,
|
||||
"active author issue lock worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
@@ -107,6 +146,7 @@ def resolve_namespace_mutation_context(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
@@ -119,21 +159,54 @@ def resolve_namespace_mutation_context(
|
||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||
repository the namespace actually mutates. Without it the single-repo
|
||||
default is preserved: the canonical root follows the process checkout.
|
||||
|
||||
Author role (#618): uses durable worktree resolution (explicit path, env,
|
||||
or active issue lock) and never silently falls back to the control checkout.
|
||||
"""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
env_map = env if env is not None else os.environ
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
|
||||
durable: dict | None = None
|
||||
if role == "author":
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
canonical_repo_root=canonical_root,
|
||||
profile_name=profile_name,
|
||||
validate=True,
|
||||
)
|
||||
workspace = durable["workspace_path"]
|
||||
binding_source = durable["workspace_binding_source"]
|
||||
if durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{binding_source} '{workspace}' not demoted: "
|
||||
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
else:
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
@@ -141,12 +214,7 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
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)
|
||||
return {
|
||||
result = {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
@@ -155,6 +223,17 @@ def resolve_namespace_mutation_context(
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
if durable is not None:
|
||||
result["author_worktree_resolution"] = durable
|
||||
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
||||
result["path_exists"] = durable.get("path_exists")
|
||||
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
||||
result["inspected_git_root"] = durable.get("inspected_git_root")
|
||||
result["author_worktree_block"] = bool(durable.get("block"))
|
||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||
result["operator_recovery"] = durable.get("operator_recovery")
|
||||
return result
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
@@ -231,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
operator_recovery: str | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
reason_list = list(reasons or [])
|
||||
# #618: prefer the durable author missing-worktree message when present.
|
||||
if role == "author" and any(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
||||
):
|
||||
return amw.format_bound_worktree_missing_error(
|
||||
{
|
||||
"reasons": reason_list,
|
||||
"binding_source": binding_source,
|
||||
"configured_path": workspace_path,
|
||||
"role_kind": role,
|
||||
"operator_recovery": operator_recovery
|
||||
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
)
|
||||
try:
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
except OSError:
|
||||
workspace = workspace_path
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
@@ -249,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
if reason_list:
|
||||
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||
if operator_recovery:
|
||||
parts.append(f"Operator recovery: {operator_recovery}")
|
||||
else:
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -269,6 +370,7 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
@@ -281,6 +383,7 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
configured_canonical_root=configured_canonical_root,
|
||||
)
|
||||
@@ -305,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
operator_recovery = ctx.get("operator_recovery")
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
# #618 durable resolution already validated existence, membership,
|
||||
# branches/, lock ownership, and traversal safety when present.
|
||||
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||
if durable_reasons:
|
||||
reasons.extend(durable_reasons)
|
||||
elif ctx.get("author_worktree_block"):
|
||||
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
else:
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
@@ -345,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||
"path_exists": ctx.get("path_exists"),
|
||||
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||
"inspected_git_root": ctx.get("inspected_git_root"),
|
||||
"operator_recovery": operator_recovery,
|
||||
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
||||
}
|
||||
@@ -0,0 +1,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`.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
Which tools actually exist is documented in
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md), which a test
|
||||
holds equal to the registered set. Never plan a mutation against a tool that is
|
||||
not listed there — that is the #781 failure mode, where a documented
|
||||
`gitea_edit_issue` did not exist until execution time.
|
||||
|
||||
@@ -168,6 +168,42 @@ Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
|
||||
proposed push before running it; `gitea_audit_stable_branch_contamination` to
|
||||
inspect or (reconciler-only) clear the marker.
|
||||
|
||||
## Runtime Recovery Protection (#630)
|
||||
|
||||
MCP connectivity is recovered through **sanctioned reconnect/restart only**:
|
||||
host auto-reconnect, an explicit client reconnect, an IDE/client relaunch, or an
|
||||
operator-owned restart. Worker sessions must never kill the daemons their own
|
||||
proof depends on.
|
||||
|
||||
**Forbidden for author/reviewer/merger sessions:**
|
||||
|
||||
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
|
||||
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
|
||||
- Any pattern broad enough to sweep unrelated namespaces (`pkill -f python`),
|
||||
even when it never names MCP.
|
||||
|
||||
**Allowed (never blocked):** read-only inspection (`ps aux | grep mcp_server`),
|
||||
and process management unrelated to the daemons — a `kill` of some other pid is
|
||||
reported as *ambiguous*, not as contamination.
|
||||
|
||||
**What happens on a detected attempt:** the session is marked
|
||||
workflow-contaminated (durable marker, redacted command summary + session id +
|
||||
remote + role). While contaminated, all review / merge / close / completion
|
||||
mutations fail closed. `comment_issue` and `lock_issue` remain allowed so the
|
||||
contaminated worker can post the durable audit comment and hand off.
|
||||
Contamination **cannot be self-cleared** — only a reconciler audit may clear it,
|
||||
and it does not expire with the session-state TTL. The final report must surface
|
||||
the contaminated recovery and must not claim a clean session.
|
||||
|
||||
Operator-authorized host maintenance stays permitted, but the authorization is
|
||||
read from the operator's environment, never from a tool argument: a session must
|
||||
not be able to authorize itself.
|
||||
|
||||
Tooling: call `gitea_record_daemon_process_kill_attempt` to classify/record a
|
||||
proposed command before running it; `gitea_audit_runtime_recovery_contamination`
|
||||
to inspect or (reconciler-only) clear the marker. Full contrast in
|
||||
`docs/mcp-namespace-eof-recovery.md`.
|
||||
|
||||
## Shell Spawn Hard-Stop Rule
|
||||
|
||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||
@@ -216,6 +252,15 @@ Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
|
||||
- Never place raw tokens in LLM/MCP config.
|
||||
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md) is the canonical
|
||||
list of registered tools, held equal to the live registry by a test. A tool that
|
||||
is not listed there does not exist — do not scope work around it (#781).
|
||||
|
||||
Issue content is edited with `gitea_edit_issue` (title/body only, read-after-write
|
||||
verified). `gitea_edit_pr` is pull-request-only and never accepts an issue number.
|
||||
|
||||
## Controller Handoff
|
||||
|
||||
Every task must end with a section titled exactly `Controller Handoff`. Compact
|
||||
@@ -224,6 +269,17 @@ format canonical field set per issue #182; mode-specific schemas in
|
||||
for the loaded workflow mode — not the legacy compact block alone.
|
||||
`review_proofs.assess_controller_handoff()` validates presence.
|
||||
|
||||
## Canonical self-propagating handoff
|
||||
|
||||
Every workflow mode also carries the cross-role handoff block defined in
|
||||
[`schemas/self-propagating-handoff.md`](schemas/self-propagating-handoff.md)
|
||||
(#626). Each actor consumes exactly one canonical handoff, performs exactly one
|
||||
authorized role, posts the result to the Gitea issue or PR thread, and emits the
|
||||
next complete handoff — until the controller records final closure. The block
|
||||
must be posted to Gitea, not returned in chat alone, and the next prompt is not
|
||||
an optional prose section. `self_propagating_handoff.py` implements the schema;
|
||||
`final_report_validator.py` enforces it as `shared.self_propagating_handoff`.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
@@ -44,3 +44,7 @@ mutations occurred).
|
||||
```
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue thread.
|
||||
|
||||
@@ -29,3 +29,7 @@ use `none` where nothing occurred. Validated by
|
||||
* Read-only diagnostics:
|
||||
* Blockers:
|
||||
* Safe next action: (fresh run for the next PR)
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread.
|
||||
|
||||
@@ -39,6 +39,7 @@ occurred).
|
||||
- Git ref mutations:
|
||||
- MCP/Gitea mutations:
|
||||
- Reconciliation mutations:
|
||||
- Terminal label cleanup:
|
||||
- External-state mutations:
|
||||
- Read-only diagnostics:
|
||||
- Blockers:
|
||||
@@ -50,4 +51,15 @@ occurred).
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
|
||||
`Terminal label cleanup` (#780) reports the `pr_open_label_cleanup` record the
|
||||
reconciliation tool returned — `clean` / `failed` / `not applicable (no linked
|
||||
issue)`, with the labels removed and preserved. Reconciliation is a terminal
|
||||
transition, so a non-`clean` record blocks any "reconciled" claim; recover with
|
||||
`gitea_cleanup_terminal_pr_labels` (`terminal_reason='retry_recovery'`) and
|
||||
confirm with `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread.
|
||||
@@ -99,6 +99,21 @@ Narrative final report and controller handoff must agree on eligibility class,
|
||||
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
|
||||
### Terminal label state (#780)
|
||||
|
||||
A run that takes a PR to a terminal state — merged, closed without merge,
|
||||
superseded, or reconciled as already landed — must report what happened to the
|
||||
linked issue's `status:pr-open` label, quoting the `pr_open_label_cleanup`
|
||||
record the terminal tool returned:
|
||||
|
||||
- Terminal label cleanup: `clean` / `failed` / `not applicable (no linked issue)`
|
||||
- Labels removed and preserved per issue, with the read-after-write read-back
|
||||
|
||||
Never claim the transition is complete while that record is not `clean`. A
|
||||
failed cleanup does not undo the merge; the safe next action is
|
||||
`gitea_cleanup_terminal_pr_labels` with `terminal_reason='retry_recovery'`,
|
||||
confirmed by `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
### Proof-backed claims (#395)
|
||||
|
||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
||||
@@ -116,4 +131,9 @@ or structured MCP metadata — not narrative alone:
|
||||
|
||||
When a claim relies on prior-session blocker state or MCP metadata only, label
|
||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread. A reviewer hands off to `merger`; a merger transitions to
|
||||
`merged-awaiting-controller` rather than declaring the work accepted.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Canonical self-propagating handoff schema (#626)
|
||||
|
||||
**Applies to:** every workflow actor — author, reviewer, merger, controller,
|
||||
operator, reconciler.
|
||||
|
||||
`#494`–`#507` defined the ledger, the canonical state comments, and the
|
||||
Canonical Thread Handoff shape. This schema owns the *chain*: each actor
|
||||
consumes exactly one canonical handoff, performs exactly one authorized role,
|
||||
records the result durably in Gitea, and emits the next complete handoff —
|
||||
until the controller records final closure.
|
||||
|
||||
Implemented and enforced by `self_propagating_handoff.py`; wired into
|
||||
`final_report_validator.py` as rule `shared.self_propagating_handoff`.
|
||||
|
||||
## The block
|
||||
|
||||
Post this block into the Gitea issue or PR thread, and include it verbatim in
|
||||
the final report. It is not an optional prose section.
|
||||
|
||||
```md
|
||||
<!-- sph:v1 -->
|
||||
## Canonical Handoff
|
||||
|
||||
```text
|
||||
REPOSITORY: <org>/<repo>
|
||||
ISSUE: <number>
|
||||
PR: <number or none>
|
||||
WORKFLOW_STATE: <one of the workflow states below>
|
||||
HEAD_SHA: <current head, or none before a branch exists>
|
||||
BASE_BRANCH: <base branch>
|
||||
BASE_OR_MERGE_SHA: <base SHA, or merge commit SHA after merge>
|
||||
ACTING_ROLE: <author|reviewer|merger|controller|operator|reconciler>
|
||||
ACTING_IDENTITY: <username (profile)>
|
||||
COMPLETED_ACTIONS: <what this actor actually did>
|
||||
VALIDATION_EVIDENCE: <commands run and their results>
|
||||
MUTATION_LEDGER: <every durable mutation performed>
|
||||
BLOCKERS: <active blockers, or none>
|
||||
NEXT_ACTOR: <role authorized by WORKFLOW_STATE, or none when complete>
|
||||
NEXT_ACTION: <exact next action, or none when complete>
|
||||
PROHIBITED_ACTIONS: <what the next actor must not do>
|
||||
NEXT_PROMPT: <complete ready-to-run prompt, or none when complete>
|
||||
WORKFLOW_FAILURE_ISSUES: <durable issue refs for tooling defects, or none>
|
||||
LAST_UPDATED: <UTC timestamp>
|
||||
```
|
||||
```
|
||||
|
||||
## Workflow states and the single authorized actor
|
||||
|
||||
| `WORKFLOW_STATE` | `NEXT_ACTOR` |
|
||||
| --------------------------- | ------------ |
|
||||
| `needs-author` | `author` |
|
||||
| `needs-review` | `reviewer` |
|
||||
| `approved-awaiting-merge` | `merger` |
|
||||
| `merged-awaiting-controller`| `controller` |
|
||||
| `blocked` | `operator` |
|
||||
| `complete` | `none` |
|
||||
|
||||
A merged PR is **not** accepted work: merge transitions to
|
||||
`merged-awaiting-controller` unless the configured workflow explicitly
|
||||
authorizes automatic acceptance.
|
||||
|
||||
## Fail-closed rules
|
||||
|
||||
* Every field is required. Only `PR`, `HEAD_SHA`, `BASE_OR_MERGE_SHA`,
|
||||
`BLOCKERS`, `WORKFLOW_FAILURE_ISSUES`, `NEXT_ACTION`, and `NEXT_PROMPT` may
|
||||
carry `none`, and `PR`/`HEAD_SHA` only in `needs-author` or `blocked`.
|
||||
* `NEXT_ACTOR` must equal the actor the declared state authorizes.
|
||||
* `blocked` requires a concrete `BLOCKERS` entry.
|
||||
* A non-terminal handoff requires a concrete `NEXT_ACTION` and a
|
||||
`NEXT_PROMPT` long enough to be ready to run.
|
||||
* `complete` must carry no `NEXT_ACTION` and no `NEXT_PROMPT`: a finished
|
||||
workflow terminates instead of manufacturing more work.
|
||||
* `NEXT_PROMPT` must name the repository and the issue, and must not depend on
|
||||
outside chat history. The issue or PR thread, workflow documentation, and
|
||||
live repository state must be sufficient to recover the task.
|
||||
* The handoff must be posted to Gitea. A chat-only report is not durable
|
||||
workflow state.
|
||||
|
||||
## Live-state recovery before acting
|
||||
|
||||
The receiving actor re-derives truth from live state instead of trusting the
|
||||
inherited handoff. `assess_handoff_live_state` detects and fails closed on:
|
||||
`changed_pr_head`, `stale_approval`, `issue_closed`, `issue_reopened`,
|
||||
`pr_merged`, `pr_closed_unmerged`, `stale_lease`, `foreign_lease`,
|
||||
`missing_worktree`, `dirty_worktree`, `namespace_mismatch`, `stale_runtime`,
|
||||
`changed_base`, and `conflicting_canonical_comments`.
|
||||
|
||||
A changed head invalidates any inherited review or merge handoff; the chain
|
||||
recovers to `needs-review`.
|
||||
|
||||
## Controller closure
|
||||
|
||||
`accept` is only honored with all four closure proofs recorded:
|
||||
`acceptance_criteria_satisfied`, `cleanup_complete`,
|
||||
`canonical_final_state_posted`, `issue_closed_through_workflow`. Otherwise the
|
||||
work item stays at `merged-awaiting-controller`.
|
||||
|
||||
`request_tests`, `request_proof`, `request_corrections`, and `reopen` return
|
||||
the work to the author; `return_to_actor` returns it to a named earlier actor.
|
||||
|
||||
## Workflow-failure escalation
|
||||
|
||||
Tooling or workflow defects found while working an item never get folded into
|
||||
the active feature issue. Each distinct failure carries `classification`,
|
||||
`linked_issue`, `temporary_impact`, `next_valid_actor`, and `recovery_prompt`.
|
||||
A failure whose signature already has a durable issue must reuse that issue
|
||||
instead of filing a duplicate.
|
||||
|
||||
## Applicability
|
||||
|
||||
Enforcement is applicability-gated exactly like the #495 canonical-state gate:
|
||||
once a report carries the `sph:v1` marker, the `Canonical Handoff` heading, or
|
||||
a `WORKFLOW_STATE:` line, the full schema is enforced and incomplete handoffs
|
||||
are rejected. Reports written before the protocol existed are unaffected.
|
||||
@@ -70,4 +70,9 @@ selected issue, and mutation ledger categories (#319, #320).
|
||||
`Read-only diagnostics` (#297).
|
||||
|
||||
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread. The next prompt is not an optional prose
|
||||
section.
|
||||
@@ -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",
|
||||
"role": "author",
|
||||
},
|
||||
# #781: editing an issue title/body is issue authoring, the same authority
|
||||
# every other non-create/non-close issue mutation gates on. Deliberately not
|
||||
# a new operation name: introducing one would silently strip the capability
|
||||
# from every already-configured author profile.
|
||||
"edit_issue": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #780: retire status:pr-open after a terminal PR transition. Same label
|
||||
# authority as set_issue_labels — it is a strictly narrower operation.
|
||||
"cleanup_terminal_pr_labels": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
"create_label": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
@@ -462,13 +476,53 @@ def preflight_task_matches(
|
||||
return False
|
||||
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
|
||||
|
||||
|
||||
# Tasks for which permission alone is insufficient: the active/configured
|
||||
# profile's declared role must also match the task role. This is the complete
|
||||
# resolver set from master at the #723 reconstruction point, shared with
|
||||
# runtime reporting so those two authorities cannot drift again.
|
||||
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||
{
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"merge_pr",
|
||||
"acquire_merger_pr_lease",
|
||||
"gitea_acquire_merger_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"gitea_adopt_merger_pr_lease",
|
||||
"release_merger_pr_lease",
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"update_pr_branch_by_merge",
|
||||
"gitea_update_pr_branch_by_merge",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
)
|
||||
|
||||
# Issue-mutating MCP tools and their resolver task keys.
|
||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_create_issue": "create_issue",
|
||||
"gitea_close_issue": "close_issue",
|
||||
"gitea_edit_issue": "edit_issue",
|
||||
"gitea_create_issue_comment": "comment_issue",
|
||||
"gitea_mark_issue": "mark_issue",
|
||||
"gitea_set_issue_labels": "set_issue_labels",
|
||||
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||
"gitea_create_label": "create_label",
|
||||
"gitea_commit_files": "commit_files",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Authoritative terminal-transition cleanup for ``status:pr-open`` (#780).
|
||||
|
||||
``status:pr-open`` is applied by ``gitea_create_pr`` while a linked pull
|
||||
request is open. Nothing removed it again: the workflow's terminal paths
|
||||
(merge, close-without-merge, supersession, already-landed reconciliation,
|
||||
controller closure) each ended without touching the label, so a repository
|
||||
audit found 40 closed issues still carrying it.
|
||||
|
||||
This module is the single source of truth for that cleanup. Every sanctioned
|
||||
terminal path plans its label mutation here rather than implementing its own
|
||||
rule, so the paths cannot drift apart:
|
||||
|
||||
- :func:`plan_pr_open_cleanup` decides the exact resulting label set. It only
|
||||
ever removes ``status:pr-open``; every other label is preserved verbatim,
|
||||
including the case where the result is an empty label set.
|
||||
- :func:`verify_pr_open_cleanup` is the read-after-write check. It proves the
|
||||
label is gone *and* that no unrelated label was dropped or added.
|
||||
- :func:`detect_residual_pr_open` is the terminal validation: given issues, it
|
||||
reports any that still carry the label, so a controller closure or audit
|
||||
fails loudly instead of leaving the leak behind.
|
||||
|
||||
The rule is idempotent by construction: an issue without the label plans no
|
||||
mutation, so retries and recovery re-runs are harmless.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: The single label this module is responsible for retiring.
|
||||
PR_OPEN_LABEL = "status:pr-open"
|
||||
|
||||
# Canonical terminal reasons — the sanctioned ways an issue can end up
|
||||
# associated with a pull request that is no longer open.
|
||||
MERGED = "merged"
|
||||
CLOSED_WITHOUT_MERGE = "closed_without_merge"
|
||||
SUPERSEDED = "superseded"
|
||||
ALREADY_LANDED = "already_landed"
|
||||
CONTROLLER_CLOSURE = "controller_closure"
|
||||
ABANDONED = "abandoned"
|
||||
RETRY_RECOVERY = "retry_recovery"
|
||||
|
||||
TERMINAL_REASONS: tuple[str, ...] = (
|
||||
MERGED,
|
||||
CLOSED_WITHOUT_MERGE,
|
||||
SUPERSEDED,
|
||||
ALREADY_LANDED,
|
||||
CONTROLLER_CLOSURE,
|
||||
ABANDONED,
|
||||
RETRY_RECOVERY,
|
||||
)
|
||||
|
||||
_REASON_ALIASES: dict[str, str] = {
|
||||
"merge": MERGED,
|
||||
"merged": MERGED,
|
||||
"pr_merged": MERGED,
|
||||
"closed": CLOSED_WITHOUT_MERGE,
|
||||
"close": CLOSED_WITHOUT_MERGE,
|
||||
"closed_without_merge": CLOSED_WITHOUT_MERGE,
|
||||
"pr_closed": CLOSED_WITHOUT_MERGE,
|
||||
"supersede": SUPERSEDED,
|
||||
"superseded": SUPERSEDED,
|
||||
"supersession": SUPERSEDED,
|
||||
"already_landed": ALREADY_LANDED,
|
||||
"reconcile_already_landed": ALREADY_LANDED,
|
||||
"controller_closure": CONTROLLER_CLOSURE,
|
||||
"close_issue": CONTROLLER_CLOSURE,
|
||||
"abandon": ABANDONED,
|
||||
"abandoned": ABANDONED,
|
||||
"retry": RETRY_RECOVERY,
|
||||
"recovery": RETRY_RECOVERY,
|
||||
"retry_recovery": RETRY_RECOVERY,
|
||||
}
|
||||
|
||||
#: Human-readable phrasing used in audit comments and diagnostics.
|
||||
REASON_DESCRIPTIONS: dict[str, str] = {
|
||||
MERGED: "the linked PR was merged",
|
||||
CLOSED_WITHOUT_MERGE: "the linked PR was closed without merging",
|
||||
SUPERSEDED: "the linked PR was superseded by another merged PR",
|
||||
ALREADY_LANDED: "the linked PR's change was already on the target branch",
|
||||
CONTROLLER_CLOSURE: "the issue reached controller closure",
|
||||
ABANDONED: "the linked PR was abandoned",
|
||||
RETRY_RECOVERY: "a partial terminal transition is being recovered",
|
||||
}
|
||||
|
||||
|
||||
def canonical_terminal_reason(reason: str | None) -> str:
|
||||
"""Normalize a terminal reason, failing closed on anything unrecognized."""
|
||||
name = (reason or "").strip()
|
||||
if name in TERMINAL_REASONS:
|
||||
return name
|
||||
normalized = name.lower().replace("-", "_").replace(" ", "_")
|
||||
try:
|
||||
return _REASON_ALIASES[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown terminal PR reason '{reason}' (expected one of: "
|
||||
+ ", ".join(TERMINAL_REASONS)
|
||||
+ ")"
|
||||
) from exc
|
||||
|
||||
|
||||
def plan_pr_open_cleanup(
|
||||
current_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the label set an issue must carry after a terminal PR transition.
|
||||
|
||||
The plan removes ``status:pr-open`` and nothing else. When the label is
|
||||
absent the plan is an explicit no-op (``cleanup_required`` False), which is
|
||||
what makes repeated cleanup calls harmless. When it was the only label the
|
||||
resulting set is legitimately empty.
|
||||
"""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
before = issue_workflow_labels.label_names(current_labels)
|
||||
after = [name for name in before if name != PR_OPEN_LABEL]
|
||||
present = len(after) != len(before)
|
||||
return {
|
||||
"terminal_reason": reason,
|
||||
"terminal_reason_description": REASON_DESCRIPTIONS[reason],
|
||||
"label": PR_OPEN_LABEL,
|
||||
"label_present": present,
|
||||
"cleanup_required": present,
|
||||
"idempotent_noop": not present,
|
||||
"labels_before": before,
|
||||
"labels_after": after,
|
||||
"removed": [PR_OPEN_LABEL] if present else [],
|
||||
"preserved": list(after),
|
||||
"empty_label_set": not after,
|
||||
}
|
||||
|
||||
|
||||
def verify_pr_open_cleanup(
|
||||
observed_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write check for a planned cleanup.
|
||||
|
||||
Verifies the label is gone and that the observed set matches the plan
|
||||
exactly, so an unrelated label silently dropped (or re-added) by the API is
|
||||
reported rather than accepted.
|
||||
"""
|
||||
observed = issue_workflow_labels.label_names(observed_labels)
|
||||
expected = list(plan.get("labels_after") or [])
|
||||
observed_set = set(observed)
|
||||
expected_set = set(expected)
|
||||
residual = PR_OPEN_LABEL in observed_set
|
||||
unexpected_removals = sorted(expected_set - observed_set)
|
||||
unexpected_additions = sorted(observed_set - expected_set - {PR_OPEN_LABEL})
|
||||
|
||||
reasons: list[str] = []
|
||||
if residual:
|
||||
reasons.append(
|
||||
f"'{PR_OPEN_LABEL}' is still present after terminal cleanup"
|
||||
)
|
||||
if unexpected_removals:
|
||||
reasons.append(
|
||||
"unrelated labels were dropped by the cleanup: "
|
||||
+ ", ".join(unexpected_removals)
|
||||
)
|
||||
if unexpected_additions:
|
||||
reasons.append(
|
||||
"unexpected labels appeared during the cleanup: "
|
||||
+ ", ".join(unexpected_additions)
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"residual": residual,
|
||||
"observed_labels": observed,
|
||||
"expected_labels": expected,
|
||||
"unexpected_removals": unexpected_removals,
|
||||
"unexpected_additions": unexpected_additions,
|
||||
"empty_label_set": not observed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
"Re-run the terminal cleanup for this issue with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' and confirm the read-back "
|
||||
f"no longer reports '{PR_OPEN_LABEL}'."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def summarize_cleanup_results(
|
||||
results: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate per-issue cleanup outcomes into one reportable record."""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
entries = [dict(entry) for entry in results]
|
||||
removed = [e.get("issue_number") for e in entries if e.get("status") == "removed"]
|
||||
absent = [
|
||||
e.get("issue_number") for e in entries if e.get("status") == "not present"
|
||||
]
|
||||
failed = [
|
||||
e.get("issue_number")
|
||||
for e in entries
|
||||
if e.get("status") not in ("removed", "not present") or not e.get("verified")
|
||||
]
|
||||
reasons: list[str] = []
|
||||
for entry in entries:
|
||||
for text in entry.get("reasons") or []:
|
||||
reasons.append(f"issue #{entry.get('issue_number')}: {text}")
|
||||
clean = not failed
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"terminal_reason": reason,
|
||||
"clean": clean,
|
||||
"checked": [e.get("issue_number") for e in entries],
|
||||
"removed": removed,
|
||||
"already_absent": absent,
|
||||
"failed": failed,
|
||||
"results": entries,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Terminal label cleanup did not complete for "
|
||||
+ ", ".join(f"#{num}" for num in failed)
|
||||
+ ". Re-run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for those issues."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def detect_residual_pr_open(
|
||||
issues: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
open_pr_issue_numbers: Iterable[int] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Terminal validation: report issues still carrying ``status:pr-open``.
|
||||
|
||||
An issue with a genuinely open pull request is allowed to keep the label,
|
||||
so *open_pr_issue_numbers* is excluded from the residual set rather than
|
||||
being reported as a leak.
|
||||
"""
|
||||
legitimate: set[int] = set()
|
||||
for num in open_pr_issue_numbers or ():
|
||||
try:
|
||||
legitimate.add(int(num))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
checked = 0
|
||||
residual: list[dict[str, Any]] = []
|
||||
exempt: list[int] = []
|
||||
|
||||
for issue in issues or []:
|
||||
checked += 1
|
||||
names = issue_workflow_labels.label_names(issue)
|
||||
if PR_OPEN_LABEL not in names:
|
||||
continue
|
||||
try:
|
||||
number = int(issue.get("number"))
|
||||
except (TypeError, ValueError):
|
||||
number = None
|
||||
if number is not None and number in legitimate:
|
||||
exempt.append(number)
|
||||
continue
|
||||
residual.append(
|
||||
{
|
||||
"number": number,
|
||||
"state": issue.get("state"),
|
||||
"labels": names,
|
||||
}
|
||||
)
|
||||
|
||||
clean = not residual
|
||||
reasons = [
|
||||
(
|
||||
f"issue #{entry['number']} ({entry.get('state') or 'unknown state'}) "
|
||||
f"still carries '{PR_OPEN_LABEL}' with no open PR"
|
||||
)
|
||||
for entry in residual
|
||||
]
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"clean": clean,
|
||||
"checked_count": checked,
|
||||
"residual_count": len(residual),
|
||||
"residual_issues": residual,
|
||||
"exempt_open_pr_issues": sorted(exempt),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for issues "
|
||||
+ ", ".join(f"#{entry['number']}" for entry in residual)
|
||||
+ " before declaring the terminal transition complete."
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -167,6 +167,35 @@ def _reset_mutation_authority(monkeypatch):
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_live_remote_master_head():
|
||||
"""#610 / PR #788 F1/F2: keep live-remote parity reads offline in tests.
|
||||
|
||||
``read_remote_master_head`` would otherwise ``git ls-remote`` whenever
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` is unset. Feature worktrees under
|
||||
``branches/`` always differ from live master, so legacy suites that assert
|
||||
runtime-context ``safe_next_action`` flip to live_stale. Module-level
|
||||
hermetic mode survives ``patch.dict(os.environ, …, clear=True)``.
|
||||
Tests that exercise the real probe path call
|
||||
``master_parity_gate.set_hermetic_test_mode(False)`` and/or set
|
||||
``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
try:
|
||||
import master_parity_gate as _mpg
|
||||
|
||||
_mpg.set_hermetic_test_mode(True)
|
||||
except Exception:
|
||||
_mpg = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _mpg is not None:
|
||||
try:
|
||||
_mpg.set_hermetic_test_mode(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deterministic_workspace_remotes():
|
||||
try:
|
||||
|
||||
@@ -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,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.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_close_issue_audited(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"state": "closed"}, {"login": "mgr-bot"}]
|
||||
# Keyed rather than positional: closing an issue also reads its labels
|
||||
# before and after the state change for the #780 terminal cleanup and
|
||||
# its read-after-write check, so call order is not a fixed sequence.
|
||||
def api(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
return {"state": "closed"}
|
||||
if "/issues/" in url:
|
||||
return {"number": 42, "labels": []}
|
||||
return {"login": "mgr-bot"}
|
||||
|
||||
mock_api.side_effect = api
|
||||
with patch.dict(os.environ, self._env(), clear=True):
|
||||
gitea_close_issue(issue_number=42, remote="prgs")
|
||||
recs = self._records()
|
||||
|
||||
@@ -79,18 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree",
|
||||
return_value=None,
|
||||
):
|
||||
with mock.patch(
|
||||
"gitea_auth.get_profile",
|
||||
return_value={"profile_name": "gitea-author"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
blob = str(ctx.exception)
|
||||
self.assertTrue(
|
||||
"Branches-only mutation guard" in blob
|
||||
or "control checkout" in blob
|
||||
or "author worktree" in blob.lower()
|
||||
or "#618" in blob,
|
||||
msg=blob,
|
||||
)
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
import mcp_server
|
||||
@@ -98,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||
healthy_ctx = {
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"workspace_role_kind": "author",
|
||||
"ignored_bindings": [],
|
||||
"process_project_root": "/repo/Gitea-Tools",
|
||||
"canonical_repo_root": "/repo/Gitea-Tools",
|
||||
"roots_aligned": True,
|
||||
"bound_worktree_missing": False,
|
||||
"author_worktree_block": False,
|
||||
"author_worktree_reasons": [],
|
||||
"author_worktree_resolution": {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"reasons": [],
|
||||
},
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": True,
|
||||
"inspected_git_root": worktree,
|
||||
}
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch.object(
|
||||
mcp_server, "_session_author_lock_worktree", return_value=None
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
with mock.patch.object(
|
||||
mcp_server,
|
||||
"_resolve_namespace_mutation_context",
|
||||
return_value=healthy_ctx,
|
||||
):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
|
||||
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(rows["schema_version"], "3")
|
||||
self.assertEqual(rows["schema_version"], "4")
|
||||
self.assertIn("DB coordinates", rows["architecture"])
|
||||
self.assertIn("bridge", rows["architecture"].lower())
|
||||
|
||||
|
||||
@@ -26,15 +26,23 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = "create_issue"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
|
||||
# Disable early return in verify_preflight_purity for testing
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
# #618: isolate from ambient session issue locks
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
self._lock_patch.stop()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
|
||||
@@ -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()
|
||||
@@ -73,12 +73,21 @@ def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
||||
def sanctioned_token(
|
||||
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
||||
):
|
||||
"""The evidence shape the server derives from a granted recovery."""
|
||||
"""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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Exact-owner renewal of an expired author issue lease (#760).
|
||||
|
||||
Covers the renewal disposition that lets the exact recorded owner re-acquire
|
||||
its own lock after the wall-clock lease expires — including while the recording
|
||||
MCP daemon PID is still alive — plus every rejection condition that must keep
|
||||
failing closed, and the pre-existing dead-PID and live-foreign dispositions
|
||||
that must remain untouched.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
|
||||
ISSUE = 5150
|
||||
BRANCH = f"fix/issue-{ISSUE}-demo"
|
||||
WORKTREE = "/scratch/wt-5150"
|
||||
HEAD = "c" * 40
|
||||
OTHER_SHA = "d" * 40
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "example-author"
|
||||
REMOTE = "prgs"
|
||||
ORG = "ExampleOrg"
|
||||
REPO = "ExampleRepo"
|
||||
|
||||
|
||||
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 past_ts(hours: int = 1) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) - timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def future_ts(hours: int = 4) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def make_lock(*, expires_at: str | None = None, pid: int | None = None, **overrides):
|
||||
"""An expired lock owned by a still-alive daemon PID — the #760 condition."""
|
||||
lock = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
# os.getpid() is unambiguously alive: the whole point of #760 is that
|
||||
# daemon liveness is not evidence of an active author task.
|
||||
"session_pid": os.getpid() if pid is None else pid,
|
||||
"lock_generation": 3,
|
||||
"work_lease": {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"branch": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||
"created_at": past_ts(5),
|
||||
"expires_at": expires_at or past_ts(),
|
||||
},
|
||||
}
|
||||
lease_overrides = overrides.pop("work_lease", None)
|
||||
if lease_overrides:
|
||||
lock["work_lease"].update(lease_overrides)
|
||||
lock.update(overrides)
|
||||
return lock
|
||||
|
||||
|
||||
def assess(lock=None, **overrides):
|
||||
"""Run the assessor with all-passing evidence unless overridden."""
|
||||
kwargs = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"identity": IDENTITY,
|
||||
"profile": PROFILE,
|
||||
"current_branch": BRANCH,
|
||||
"porcelain_status": "",
|
||||
"worktree_exists": True,
|
||||
"head_sha": HEAD,
|
||||
"remote_head_sha": HEAD,
|
||||
"pr_head_sha": None,
|
||||
"pr_number": None,
|
||||
"competing_live_locks": [],
|
||||
"candidate_branches": [BRANCH],
|
||||
"current_pid": 4242,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return issue_lock_renewal.assess_exact_owner_lease_renewal(
|
||||
make_lock() if lock is None else lock, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class ExactOwnerRenewalGranted(unittest.TestCase):
|
||||
"""AC1/AC3-AC7: the positive path."""
|
||||
|
||||
def test_expired_lease_alive_pid_exact_owner_is_renewable(self):
|
||||
result = assess()
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.RENEWAL_SANCTIONED)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
self.assertTrue(result["is_candidate"])
|
||||
|
||||
def test_renewal_holds_when_owning_pr_head_matches(self):
|
||||
result = assess(pr_number=999, pr_head_sha=HEAD)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
def test_evidence_records_both_sides_of_the_transition(self):
|
||||
result = assess()
|
||||
evidence = result["evidence"]
|
||||
self.assertEqual(evidence["prior_pid"], os.getpid())
|
||||
self.assertTrue(evidence["prior_pid_alive"])
|
||||
self.assertEqual(evidence["replacement_pid"], 4242)
|
||||
self.assertTrue(evidence["prior_expires_at"])
|
||||
|
||||
|
||||
class ExactOwnerRenewalRefused(unittest.TestCase):
|
||||
"""AC3-AC8: every near-match must fail closed, one reason at a time."""
|
||||
|
||||
def _refused(self, **overrides):
|
||||
result = assess(**overrides)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
self.assertFalse(result["renewal_sanctioned"])
|
||||
self.assertTrue(result["reasons"])
|
||||
return result
|
||||
|
||||
def test_different_branch_refused(self):
|
||||
result = self._refused(branch_name=f"fix/issue-{ISSUE}-other")
|
||||
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_worktree_refused(self):
|
||||
result = self._refused(worktree_path="/scratch/somewhere-else")
|
||||
self.assertTrue(any("worktree" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_claimant_refused(self):
|
||||
result = self._refused(identity="someone-else")
|
||||
self.assertTrue(any("claimant" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_profile_refused(self):
|
||||
result = self._refused(profile="other-author")
|
||||
self.assertTrue(any("profile" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_remote_org_or_repo_refused(self):
|
||||
self._refused(remote="dadeschools")
|
||||
self._refused(org="OtherOrg")
|
||||
self._refused(repo="OtherRepo")
|
||||
|
||||
def test_dirty_worktree_refused(self):
|
||||
result = self._refused(porcelain_status=" M gitea_mcp_server.py\n")
|
||||
self.assertTrue(any("uncommitted" in r for r in result["reasons"]))
|
||||
|
||||
def test_missing_worktree_refused(self):
|
||||
result = self._refused(worktree_exists=False)
|
||||
self.assertTrue(any("does not exist" in r for r in result["reasons"]))
|
||||
|
||||
def test_worktree_on_wrong_branch_refused(self):
|
||||
self._refused(current_branch="master")
|
||||
|
||||
def test_local_and_remote_head_mismatch_refused(self):
|
||||
result = self._refused(remote_head_sha=OTHER_SHA)
|
||||
self.assertTrue(
|
||||
any("does not equal remote head" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_unpublished_branch_refused(self):
|
||||
result = self._refused(remote_head_sha=None)
|
||||
self.assertTrue(any("remote branch head" in r for r in result["reasons"]))
|
||||
|
||||
def test_pr_head_mismatch_refused(self):
|
||||
result = self._refused(pr_number=999, pr_head_sha=OTHER_SHA)
|
||||
self.assertTrue(any("does not equal local" in r for r in result["reasons"]))
|
||||
|
||||
def test_unobservable_pr_head_refused(self):
|
||||
self._refused(pr_number=999, pr_head_sha=None)
|
||||
|
||||
def test_competing_live_lock_on_same_issue_refused(self):
|
||||
result = self._refused(
|
||||
competing_live_locks=[
|
||||
{"issue_number": ISSUE, "branch_name": BRANCH, "pid": 777}
|
||||
]
|
||||
)
|
||||
self.assertTrue(any("live lock" in r for r in result["reasons"]))
|
||||
|
||||
def test_competing_live_lock_holding_the_branch_refused(self):
|
||||
self._refused(
|
||||
competing_live_locks=[
|
||||
{"issue_number": 111, "branch_name": BRANCH, "worktree_path": ""}
|
||||
]
|
||||
)
|
||||
|
||||
def test_competing_branch_claim_refused(self):
|
||||
result = self._refused(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"])
|
||||
self.assertTrue(any("issue marker" in r for r in result["reasons"]))
|
||||
|
||||
def test_malformed_durable_lock_refused(self):
|
||||
lock = make_lock()
|
||||
lock["worktree_path"] = ""
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
|
||||
def test_lock_without_recorded_claimant_refused(self):
|
||||
lock = make_lock()
|
||||
lock["work_lease"]["claimant"] = {}
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
|
||||
|
||||
class NotARenewalCandidate(unittest.TestCase):
|
||||
"""AC12 and scope: situations renewal must decline to judge at all."""
|
||||
|
||||
def test_live_foreign_lease_is_never_a_candidate(self):
|
||||
lock = make_lock(expires_at=future_ts())
|
||||
result = assess(lock, identity="someone-else")
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
self.assertFalse(result["renewal_sanctioned"])
|
||||
|
||||
def test_unexpired_lease_is_never_a_candidate(self):
|
||||
lock = make_lock(expires_at=future_ts())
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_dead_pid_under_unexpired_lease_stays_with_753(self):
|
||||
"""The opposite trigger; #760 must not re-own it."""
|
||||
lock = make_lock(expires_at=future_ts(), pid=dead_pid())
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_absent_lock_is_not_a_candidate(self):
|
||||
result = assess({})
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_different_issue_is_not_a_candidate(self):
|
||||
lock = make_lock()
|
||||
lock["issue_number"] = ISSUE + 1
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_different_operation_type_is_not_a_candidate(self):
|
||||
lock = make_lock()
|
||||
lock["work_lease"]["operation_type"] = "review_pr_work"
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
|
||||
class DaemonPidIsNotTaskLiveness(unittest.TestCase):
|
||||
"""AC16: a live recorded PID is never, by itself, authorization."""
|
||||
|
||||
def test_alive_pid_alone_does_not_authorize_renewal(self):
|
||||
# Every ownership fact except the live PID is wrong.
|
||||
result = assess(identity="someone-else", branch_name="fix/issue-1-nope")
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
self.assertTrue(result["evidence"]["prior_pid_alive"])
|
||||
|
||||
def test_renewal_does_not_require_a_dead_pid(self):
|
||||
result = assess()
|
||||
self.assertTrue(result["evidence"]["prior_pid_alive"])
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
def test_dead_pid_does_not_block_an_otherwise_exact_owner(self):
|
||||
lock = make_lock(pid=dead_pid())
|
||||
result = assess(lock)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
|
||||
class ConflictGateOrdering(unittest.TestCase):
|
||||
"""AC2: the same-owner allowance is reachable on an expired lease.
|
||||
|
||||
These cases need a worktree that genuinely exists on disk. The #601 reclaim
|
||||
affordance already permits takeover when the recorded worktree is missing,
|
||||
so a fictional path would satisfy the gate for the wrong reason and never
|
||||
exercise the ordering defect this issue is about.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._tmp = tempfile.TemporaryDirectory()
|
||||
cls.worktree = cls._tmp.name
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._tmp.cleanup()
|
||||
|
||||
def present_lock(self, **overrides):
|
||||
return make_lock(worktree_path=self.worktree, **overrides)
|
||||
|
||||
def test_expired_same_owner_is_allowed_when_renewal_is_sanctioned(self):
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(),
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
def test_expired_same_owner_still_blocks_without_the_waiver(self):
|
||||
"""Regression for the ordering defect: no waiver, no change in behavior.
|
||||
|
||||
Live PID and a present worktree, so the #601 reclaim affordance refuses;
|
||||
before #760 this was the permanent dead end for an exact owner.
|
||||
"""
|
||||
lock = self.present_lock()
|
||||
self.assertFalse(
|
||||
issue_lock_store.assess_expired_lock_reclaim(lock)["reclaim_allowed"]
|
||||
)
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
lock,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("Recovery review is required", block)
|
||||
|
||||
def test_waiver_does_not_unlock_a_different_owner(self):
|
||||
"""AC11: the waiver is scoped by same_owner, not merely by its own flag."""
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(),
|
||||
issue_number=ISSUE,
|
||||
branch_name=f"fix/issue-{ISSUE}-someone-else",
|
||||
worktree_path=self.worktree,
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("Recovery review is required", block)
|
||||
|
||||
def test_live_lease_disposition_is_unchanged(self):
|
||||
"""AC12: a live foreign lease still blocks, waiver or not."""
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(expires_at=future_ts()),
|
||||
issue_number=ISSUE,
|
||||
branch_name=f"fix/issue-{ISSUE}-someone-else",
|
||||
worktree_path="/scratch/other",
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("already has an active", block)
|
||||
|
||||
def test_dead_pid_reclaim_path_is_unchanged(self):
|
||||
"""AC11: expired + dead PID still reclaims through the #601 affordance."""
|
||||
lock = self.present_lock(pid=dead_pid())
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||
self.assertTrue(reclaim["reclaim_allowed"])
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
lock,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
|
||||
class RenewalRecordAndDownstream(unittest.TestCase):
|
||||
"""AC9/AC10: durable audit trail, and a renewed lock that actually works."""
|
||||
|
||||
def test_record_captures_prior_and_replacement_state(self):
|
||||
assessment = assess()
|
||||
record = issue_lock_renewal.build_renewal_record(
|
||||
assessment,
|
||||
renewed_at="2026-01-01T00:00:00Z",
|
||||
new_expires_at="2026-01-01T04:00:00Z",
|
||||
)
|
||||
self.assertTrue(record["renewed"])
|
||||
self.assertEqual(record["prior_pid"], os.getpid())
|
||||
self.assertEqual(record["new_expires_at"], "2026-01-01T04:00:00Z")
|
||||
self.assertEqual(record["renewed_at"], "2026-01-01T00:00:00Z")
|
||||
self.assertEqual(record["identity"], IDENTITY)
|
||||
self.assertEqual(record["profile"], PROFILE)
|
||||
self.assertTrue(record["prior_expires_at"])
|
||||
self.assertTrue(record["proof"])
|
||||
|
||||
def test_renewed_lock_satisfies_verify_lock_for_mutation(self):
|
||||
renewed = make_lock(expires_at=future_ts())
|
||||
renewed["session_pid"] = os.getpid()
|
||||
renewed["lease_renewal"] = {"renewed": True}
|
||||
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||
renewed,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
)
|
||||
self.assertTrue(verdict["proven"])
|
||||
self.assertFalse(verdict["block"])
|
||||
|
||||
def test_refusal_message_names_the_missing_evidence(self):
|
||||
assessment = assess(porcelain_status=" M gitea_mcp_server.py\n")
|
||||
message = issue_lock_renewal.format_renewal_refusal(assessment)
|
||||
self.assertIn("refused", message)
|
||||
self.assertIn("uncommitted", message)
|
||||
|
||||
|
||||
class NoCallerControlledRenewalFlag(unittest.TestCase):
|
||||
"""AC14: renewal eligibility is never declarable by a caller."""
|
||||
|
||||
def test_lock_issue_tool_exposes_no_renewal_parameter(self):
|
||||
import gitea_mcp_server
|
||||
|
||||
target = gitea_mcp_server.gitea_lock_issue
|
||||
target = getattr(target, "fn", getattr(target, "__wrapped__", target))
|
||||
params = set(inspect.signature(target).parameters)
|
||||
for forbidden in ("renewal_sanctioned", "renew", "allow_renewal", "is_owner"):
|
||||
self.assertNotIn(forbidden, params)
|
||||
|
||||
def test_store_defaults_to_no_waiver(self):
|
||||
params = inspect.signature(
|
||||
issue_lock_store.assess_same_issue_lease_conflict
|
||||
).parameters
|
||||
self.assertIs(params["renewal_sanctioned"].default, False)
|
||||
bind_params = inspect.signature(issue_lock_store.bind_session_lock).parameters
|
||||
self.assertIs(bind_params["renewal_sanctioned"].default, False)
|
||||
|
||||
|
||||
class NoIssueNumberSpecialCasing(unittest.TestCase):
|
||||
"""AC17: no repository issue or PR number is special-cased."""
|
||||
|
||||
def test_module_contains_no_hardcoded_issue_special_cases(self):
|
||||
source = inspect.getsource(issue_lock_renewal)
|
||||
code = "\n".join(
|
||||
line for line in source.splitlines() if not line.strip().startswith("#")
|
||||
)
|
||||
for literal in ("757", "759", "760"):
|
||||
self.assertNotIn(f"== {literal}", code)
|
||||
self.assertNotIn(f"issue_number == {literal}", code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,342 @@
|
||||
"""MCP-level exact-owner lease renewal through ``gitea_lock_issue`` (#760).
|
||||
|
||||
The unit suite in ``test_issue_760_exact_owner_lease_renewal`` proves the
|
||||
renewal *disposition*. It cannot prove the disposition survives the rest of the
|
||||
tool, and it did not: the waiver was computed and then discarded before
|
||||
``assess_issue_lock_worktree``, so every real renewal still failed on
|
||||
base-equivalence. A branch being renewed always carries committed work, so it is
|
||||
never base-equivalent by construction — exactly the argument #753 already makes
|
||||
for recovery.
|
||||
|
||||
These tests drive the public tool end to end against a real git repository and a
|
||||
real durable lock file, composing every gate in the production order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
|
||||
ISSUE = 9760
|
||||
BRANCH = f"fix/issue-{ISSUE}-renewal-mcp"
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
|
||||
def _past_ts(hours: int = 1) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) - timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _RenewalMcpBase(unittest.TestCase):
|
||||
"""Real git repo + durable expired lock owned by a live PID.
|
||||
|
||||
The recorded PID is ``os.getpid()`` — unambiguously alive. That is the whole
|
||||
point of #760: the PID belongs to the long-lived MCP daemon, so its liveness
|
||||
says nothing about whether the authoring task still holds the work.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.repo = tempfile.mkdtemp(prefix="issue760-mcp-")
|
||||
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
|
||||
self._init_worktree()
|
||||
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 _git(self, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", self.repo, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _init_worktree(self):
|
||||
self._git("init", "-q", "-b", "master")
|
||||
self._git("config", "user.email", "[email protected]")
|
||||
self._git("config", "user.name", "Test")
|
||||
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
|
||||
fh.write("seed\n")
|
||||
self._git("add", "seed.txt")
|
||||
self._git("commit", "-q", "-m", "seed")
|
||||
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
# The branch carries committed work, so it is NOT base-equivalent.
|
||||
self._git("checkout", "-q", "-b", BRANCH)
|
||||
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
|
||||
fh.write("author work\n")
|
||||
self._git("add", "work.txt")
|
||||
self._git("commit", "-q", "-m", "author work")
|
||||
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self.worktree = os.path.realpath(self.repo)
|
||||
|
||||
def write_expired_lock(self, **overrides):
|
||||
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}
|
||||
pid = overrides.pop("session_pid", os.getpid())
|
||||
overrides.pop("pid", None)
|
||||
lease_overrides = overrides.pop("work_lease", {})
|
||||
data = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": self.worktree,
|
||||
"session_pid": pid,
|
||||
"pid": pid,
|
||||
"lock_generation": 3,
|
||||
"work_lease": {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": BRANCH,
|
||||
"worktree_path": self.worktree,
|
||||
"claimant": claimant,
|
||||
"created_at": _past_ts(5),
|
||||
"last_heartbeat_at": _past_ts(5),
|
||||
"expires_at": _past_ts(), # already expired
|
||||
},
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=claimant,
|
||||
),
|
||||
}
|
||||
data["work_lease"].update(lease_overrides)
|
||||
data.update(overrides)
|
||||
data["session_pid"] = pid
|
||||
data["pid"] = pid
|
||||
data["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, data)
|
||||
return path
|
||||
|
||||
def _tool_env(self):
|
||||
env = shared_mutation_env(
|
||||
PROFILE,
|
||||
include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||
)
|
||||
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return env
|
||||
|
||||
def _git_state(self, *, porcelain="", branch=BRANCH, head=None):
|
||||
return {
|
||||
"current_branch": branch,
|
||||
"porcelain_status": porcelain,
|
||||
# The decisive fact: a branch carrying work is never base-equivalent.
|
||||
"base_equivalent": False,
|
||||
"head_sha": head or self.head_sha,
|
||||
"inspected_git_root": self.worktree,
|
||||
"base_branch": "master",
|
||||
}
|
||||
|
||||
def run_lock_issue(
|
||||
self,
|
||||
*,
|
||||
branch_entries=None,
|
||||
open_prs=None,
|
||||
git_state=None,
|
||||
identity=IDENTITY,
|
||||
profile=PROFILE,
|
||||
):
|
||||
"""Drive the public tool for the published exact-owner renewal shape."""
|
||||
if branch_entries is None:
|
||||
branch_entries = [{"name": BRANCH, "commit": {"id": self.head_sha}}]
|
||||
if open_prs is None:
|
||||
open_prs = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
|
||||
if git_state is None:
|
||||
git_state = self._git_state()
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server.api_get_all", return_value=list(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=git_state,
|
||||
), patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=lambda h, o, r, auth, issue_number: (
|
||||
list(open_prs),
|
||||
[b.get("name") for b in branch_entries if isinstance(b, dict)],
|
||||
{"status": "not_claimed"},
|
||||
),
|
||||
), 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 TestRenewalReachableThroughTool(_RenewalMcpBase):
|
||||
"""F1: the sanctioned renewal must survive every downstream gate."""
|
||||
|
||||
def test_expired_lease_live_pid_exact_owner_renews_through_the_tool(self):
|
||||
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
|
||||
self.assertTrue(issue_lock_store.is_lease_expired(prior))
|
||||
self.assertTrue(issue_lock_store.is_process_alive(prior["session_pid"]))
|
||||
|
||||
result = self.run_lock_issue()
|
||||
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(result["issue_number"], ISSUE)
|
||||
self.assertEqual(result["branch_name"], BRANCH)
|
||||
# The renewal is reported natively, so no lock-file inspection is needed.
|
||||
self.assertIn("lease_renewal", result)
|
||||
self.assertTrue(result["lease_renewal"]["renewed"])
|
||||
self.assertIn("Renewed the expired", result["message"])
|
||||
|
||||
def test_renewed_lock_records_prior_and_replacement_evidence(self):
|
||||
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
|
||||
prior_expiry = prior["work_lease"]["expires_at"]
|
||||
prior_generation = issue_lock_store.lock_generation(prior)
|
||||
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
|
||||
renewal = written["lease_renewal"]
|
||||
self.assertTrue(renewal["renewed"])
|
||||
self.assertEqual(renewal["prior_pid"], prior["session_pid"])
|
||||
self.assertTrue(renewal["prior_pid_alive"])
|
||||
self.assertEqual(renewal["prior_expires_at"], prior_expiry)
|
||||
self.assertEqual(renewal["identity"], IDENTITY)
|
||||
self.assertEqual(renewal["profile"], PROFILE)
|
||||
self.assertEqual(renewal["head_sha"], self.head_sha)
|
||||
self.assertTrue(renewal["proof"])
|
||||
# New expiry is a fresh absolute stamp, later than the one it replaced.
|
||||
self.assertEqual(renewal["new_expires_at"], written["work_lease"]["expires_at"])
|
||||
self.assertGreater(renewal["new_expires_at"], prior_expiry)
|
||||
# Compare-and-swap advanced the generation exactly once.
|
||||
self.assertEqual(
|
||||
issue_lock_store.lock_generation(written), prior_generation + 1
|
||||
)
|
||||
|
||||
def test_renewed_lock_is_live_and_satisfies_mutation_ownership(self):
|
||||
self.write_expired_lock()
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
|
||||
self.assertTrue(issue_lock_store.assess_lock_freshness(written)["live"])
|
||||
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||
written,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertTrue(verdict["proven"], verdict)
|
||||
self.assertFalse(verdict["block"])
|
||||
|
||||
def test_recovery_record_is_not_written_for_a_live_owner_renewal(self):
|
||||
"""#753 recovery must not be claimed when the recorded PID is alive."""
|
||||
self.write_expired_lock()
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
self.assertNotIn("dead_session_recovery", written)
|
||||
|
||||
|
||||
class TestRenewalWaiverIsNarrow(_RenewalMcpBase):
|
||||
"""The waiver relaxes base-equivalence and nothing else."""
|
||||
|
||||
def test_dirty_worktree_still_blocks_a_would_be_renewal(self):
|
||||
"""Cleanliness is never waived; the renewal assessor refuses first.
|
||||
|
||||
A dirty worktree makes the renewal refuse, so no waiver is issued and
|
||||
the lease-conflict gate fails closed ahead of the worktree gate. The
|
||||
refusal names the uncommitted files, so the owner still learns why.
|
||||
"""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(
|
||||
git_state=self._git_state(porcelain=" M gitea_mcp_server.py\n")
|
||||
)
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Recovery review is required before takeover", message)
|
||||
self.assertIn("worktree has uncommitted tracked changes", message)
|
||||
self.assertIn("gitea_mcp_server.py", message)
|
||||
|
||||
def test_foreign_claimant_cannot_use_the_waiver(self):
|
||||
"""A near-match owner gets no renewal and no base-equivalence waiver."""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(identity="someone-else")
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Recovery review is required before takeover", message)
|
||||
# The refusal names the missing ownership evidence (#760 diagnostics).
|
||||
self.assertIn("does not match active identity", message)
|
||||
|
||||
def test_foreign_profile_cannot_use_the_waiver(self):
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(profile="other-author")
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_unpublished_branch_cannot_use_the_waiver(self):
|
||||
"""No remote head to agree with, so exact-owner renewal is refused."""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(branch_entries=[], open_prs=[])
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_pr_head_mismatch_cannot_use_the_waiver(self):
|
||||
self.write_expired_lock()
|
||||
other = "9" * 40
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(
|
||||
open_prs=[{"number": 4242, "head": {"ref": BRANCH, "sha": other}}]
|
||||
)
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_non_base_equivalent_branch_still_blocks_without_any_waiver(self):
|
||||
"""No durable lock at all: the ordinary base-equivalence rule applies."""
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue()
|
||||
self.assertIn("must be base-equivalent", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -78,6 +78,95 @@ class TestBlockReasonsAndReport(unittest.TestCase):
|
||||
self.assertTrue(report["recovery"])
|
||||
|
||||
|
||||
class TestLiveRemoteParity(unittest.TestCase):
|
||||
"""#610: parity must account for the live remote master, not just local.
|
||||
|
||||
The daemon can be stale relative to the live remote target while the local
|
||||
checkout HEAD still matches the daemon's startup commit, so local parity
|
||||
reports green even though a mutation would run against outdated code.
|
||||
"""
|
||||
|
||||
SHA_C = "c" * 40
|
||||
|
||||
def test_distinguishes_three_shas(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertEqual(res["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(res["local_head"], SHA_A)
|
||||
self.assertEqual(res["live_remote_head"], SHA_B)
|
||||
|
||||
def test_mutation_safe_only_when_all_three_match(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
|
||||
self.assertTrue(res["mutation_safe"])
|
||||
self.assertTrue(res["live_known"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
|
||||
def test_live_stale_when_remote_advanced_past_daemon(self):
|
||||
# Local checkout still matches the daemon start (local parity green),
|
||||
# but the live remote master has advanced -> daemon is live-stale.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(res["in_parity"]) # local parity still green
|
||||
self.assertTrue(res["live_stale"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
|
||||
|
||||
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
|
||||
# Non-goal: unfetchable live remote must not be treated as stale for
|
||||
# read-only, but a mutation-safe claim fails closed.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
|
||||
self.assertFalse(res["live_known"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertTrue(res["in_parity"])
|
||||
|
||||
def test_default_live_remote_preserves_legacy_shape(self):
|
||||
# Callers that do not supply a live head keep the pre-#610 behavior:
|
||||
# in-parity, not live-stale, no live-derived block.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
|
||||
class TestLiveStaleBlockAndReport(unittest.TestCase):
|
||||
"""#610: live-staleness must block mutations and surface a typed blocker."""
|
||||
|
||||
def test_live_stale_produces_block_reasons(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(mp.parity_block_reasons(res))
|
||||
|
||||
def test_disable_env_suppresses_live_stale_block(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
def test_resolver_disagreement_returns_typed_blocker(self):
|
||||
# Parity says local-green, resolver says restart required -> disagreement
|
||||
# is a typed, fail-closed blocker naming the resolver as authoritative.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
|
||||
self.assertIsNotNone(blocker)
|
||||
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
|
||||
self.assertTrue(blocker["restart_required"])
|
||||
self.assertTrue(blocker["resolver_authoritative"])
|
||||
|
||||
def test_no_disagreement_when_resolver_agrees(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertIsNone(
|
||||
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
|
||||
|
||||
def test_live_stale_report_names_live_remote(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
report = mp.parity_report(res)
|
||||
self.assertEqual(report["live_remote_head"], SHA_B)
|
||||
self.assertTrue(report["restart_required"])
|
||||
|
||||
|
||||
class TestReadGitHead(unittest.TestCase):
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
@@ -95,6 +184,149 @@ class TestReadGitHead(unittest.TestCase):
|
||||
self.assertIsNone(mp.read_git_head(""))
|
||||
|
||||
|
||||
class TestReadRemoteMasterHead(unittest.TestCase):
|
||||
"""#610: live remote master head reader (env-overridable, fails to None)."""
|
||||
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
|
||||
|
||||
def test_blank_override_is_none(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
|
||||
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
|
||||
|
||||
def test_unfetchable_remote_is_none(self):
|
||||
# No override; a bogus root/remote must fail closed to None, never raise.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertIsNone(
|
||||
mp.read_remote_master_head("/nonexistent", remote="nope"))
|
||||
|
||||
|
||||
class TestRemoteHeadCache(unittest.TestCase):
|
||||
"""#610: live remote reads are cached with a TTL to stay off the network.
|
||||
|
||||
The parity gate runs on every mutation and every runtime-context read, so an
|
||||
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# These cases intentionally exercise the subprocess/cache path, so they
|
||||
# opt out of suite-wide hermetic mode (PR #788 F1).
|
||||
self._saved_hermetic = mp.hermetic_test_mode()
|
||||
mp.set_hermetic_test_mode(False)
|
||||
mp._clear_remote_head_cache()
|
||||
env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
|
||||
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE,
|
||||
"PYTEST_CURRENT_TEST")
|
||||
}
|
||||
# Allow the probe path under hermetic defenses while still mocking
|
||||
# subprocess so no real network call runs.
|
||||
env[mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE] = "1"
|
||||
self._env = patch.dict(os.environ, env, clear=True)
|
||||
self._env.start()
|
||||
self.addCleanup(self._env.stop)
|
||||
self.addCleanup(mp._clear_remote_head_cache)
|
||||
self.addCleanup(
|
||||
lambda: mp.set_hermetic_test_mode(self._saved_hermetic)
|
||||
)
|
||||
|
||||
def _fake_run(self, sha):
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = f"{sha}\trefs/heads/master\n"
|
||||
calls = {"n": 0}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return _R()
|
||||
return run, calls
|
||||
|
||||
def test_second_call_within_ttl_uses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
self.assertEqual(a, SHA_B)
|
||||
self.assertEqual(b, SHA_B)
|
||||
self.assertEqual(calls["n"], 1)
|
||||
|
||||
def test_zero_ttl_bypasses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
self.assertEqual(calls["n"], 2)
|
||||
|
||||
def test_env_override_never_touches_subprocess(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
self.assertEqual(
|
||||
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
|
||||
self.assertEqual(calls["n"], 0)
|
||||
|
||||
|
||||
class TestHermeticLiveRemoteReads(unittest.TestCase):
|
||||
"""#610 / PR #788 F1/F2: suite hermetic mode never hits the network."""
|
||||
|
||||
def setUp(self):
|
||||
self._saved = mp.hermetic_test_mode()
|
||||
mp.set_hermetic_test_mode(True)
|
||||
mp._clear_remote_head_cache()
|
||||
self.addCleanup(lambda: mp.set_hermetic_test_mode(self._saved))
|
||||
self.addCleanup(mp._clear_remote_head_cache)
|
||||
|
||||
def test_hermetic_mode_returns_none_without_subprocess(self):
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("ls-remote must not run under hermetic mode")
|
||||
|
||||
env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
|
||||
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE)
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertIsNone(
|
||||
mp.read_remote_master_head("/repo", remote="prgs")
|
||||
)
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
def test_hermetic_mode_survives_clear_true_env(self):
|
||||
"""Module flag, not env pin: clear=True cannot re-enable the probe."""
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("ls-remote must not run after clear=True")
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertIsNone(mp.read_remote_master_head("/repo"))
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
def test_explicit_override_still_wins_under_hermetic(self):
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("override must bypass subprocess")
|
||||
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertEqual(
|
||||
mp.read_remote_master_head("/repo"), SHA_B
|
||||
)
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
|
||||
class TestServerWiring(unittest.TestCase):
|
||||
"""Integration with the gate choke point in the server namespace."""
|
||||
|
||||
@@ -105,6 +337,13 @@ class TestServerWiring(unittest.TestCase):
|
||||
self._saved = self.srv._STARTUP_PARITY
|
||||
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
||||
"startup_head": SHA_A}
|
||||
# Keep the live-remote read hermetic (no real ls-remote network call):
|
||||
# default the live master to the daemon start so parity is fully green
|
||||
# unless a test overrides the live head explicitly (#610).
|
||||
self._live_patch = patch.dict(
|
||||
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
|
||||
self._live_patch.start()
|
||||
self.addCleanup(self._live_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.srv._STARTUP_PARITY = self._saved
|
||||
@@ -147,6 +386,36 @@ class TestServerWiring(unittest.TestCase):
|
||||
self.assertTrue(out["in_parity"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
# --- #610: live-remote wiring -------------------------------------------
|
||||
|
||||
def test_live_stale_blocks_mutation_though_local_green(self):
|
||||
# Local checkout matches the daemon start (local parity green) but the
|
||||
# live remote master has advanced -> mutations must fail closed.
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
||||
self.assertTrue(
|
||||
self.srv._master_parity_block("gitea.pr.create"))
|
||||
|
||||
def test_assess_tool_exposes_three_distinct_shas(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertEqual(out["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(out["local_head"], SHA_A)
|
||||
self.assertEqual(out["live_remote_head"], SHA_B)
|
||||
self.assertTrue(out["live_stale"])
|
||||
self.assertFalse(out["mutation_safe"])
|
||||
self.assertIn("report", out)
|
||||
|
||||
def test_assess_tool_mutation_safe_when_all_three_match(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertTrue(out["mutation_safe"])
|
||||
self.assertFalse(out["live_stale"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,6 +10,7 @@ DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
|
||||
|
||||
REQUIRED_MENU_LABELS = (
|
||||
"Project status / root checkout health",
|
||||
"Workflow dashboard (queue, leases, next safe action)",
|
||||
"Author workflow prompts",
|
||||
"Reviewer workflow prompts",
|
||||
"Merger workflow prompts",
|
||||
@@ -105,6 +106,23 @@ class TestMcpMenuScript(unittest.TestCase):
|
||||
self.assertIn("./mcp-menu.sh", docs_text)
|
||||
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):
|
||||
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
|
||||
# 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",
|
||||
return_value={"current_branch": "master"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
with mock.patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree",
|
||||
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("os.path.isdir", return_value=True)
|
||||
|
||||
@@ -242,6 +242,32 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertTrue(res.get("stop_required"))
|
||||
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
|
||||
def test_issue_comment_does_not_imply_close(self):
|
||||
# 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
|
||||
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
|
||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||
@@ -112,6 +117,42 @@ FORMAL_REVIEW_TASKS = (
|
||||
"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):
|
||||
"""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__":
|
||||
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,458 @@
|
||||
"""Tests for the worker registry and configuration schema (#798, epic #797)."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from webui.worker_registry import (
|
||||
ALLOWED_ROLES,
|
||||
SCHEMA_VERSION,
|
||||
RegistryValidationError,
|
||||
WorkerRegistry,
|
||||
default_registry_path,
|
||||
find_provider,
|
||||
find_worker,
|
||||
history_dir,
|
||||
list_revisions,
|
||||
load_registry,
|
||||
registry_to_dict,
|
||||
registry_to_document,
|
||||
rollback_to_revision,
|
||||
save_registry,
|
||||
validate_payload,
|
||||
worker_to_dict,
|
||||
workers_for_provider,
|
||||
)
|
||||
|
||||
_EXPECTED_PROVIDER_IDS = ("claude", "grok", "codex", "agy", "kimi-k")
|
||||
|
||||
|
||||
def _provider(provider_id: str = "claude", **overrides) -> dict:
|
||||
payload = {
|
||||
"id": provider_id,
|
||||
"display_name": "Claude",
|
||||
"vendor": "Anthropic",
|
||||
"executable": "claude",
|
||||
"available": True,
|
||||
"models": ["claude-opus-4-8"],
|
||||
"notes": "",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _worker(worker_id: str = "claude-author", **overrides) -> dict:
|
||||
payload = {
|
||||
"id": worker_id,
|
||||
"display_name": "Claude author",
|
||||
"provider": "claude",
|
||||
"model": "claude-opus-4-8",
|
||||
"project": "gitea-tools",
|
||||
"role": "author",
|
||||
"namespace": "gitea-author",
|
||||
"profile": "prgs-author",
|
||||
"workflow": "skills/llm-project-workflow/workflows/work-issue.md",
|
||||
"schedule": {"kind": "cron", "expression": "0 * * * *"},
|
||||
"timeout_seconds": 3600,
|
||||
"enabled": True,
|
||||
"scheduler": {"kind": "launchd", "label": "cc.prgs.claude.author"},
|
||||
"notes": "",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _document(providers=None, workers=None, **overrides) -> dict:
|
||||
payload = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"revision": 1,
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"providers": providers if providers is not None else [_provider()],
|
||||
"workers": workers if workers is not None else [_worker()],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class _TempRegistryCase(unittest.TestCase):
|
||||
"""Base case giving each test an isolated registry file."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.path = Path(self._tmp.name) / "workers.registry.json"
|
||||
|
||||
def write(self, document: dict) -> Path:
|
||||
self.path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||
return self.path
|
||||
|
||||
def parse(self, document: dict) -> WorkerRegistry:
|
||||
return validate_payload(document, source_path=self.path)
|
||||
|
||||
|
||||
class TestPackagedRegistry(unittest.TestCase):
|
||||
"""AC: the declarative registry is the source of truth and ships with the app."""
|
||||
|
||||
def test_default_path_points_at_packaged_data(self):
|
||||
path = default_registry_path()
|
||||
self.assertEqual(path.name, "workers.registry.json")
|
||||
self.assertEqual(path.parent.name, "data")
|
||||
|
||||
def test_packaged_registry_loads_and_validates(self):
|
||||
registry = load_registry()
|
||||
self.assertEqual(registry.version, SCHEMA_VERSION)
|
||||
self.assertGreaterEqual(registry.revision, 1)
|
||||
|
||||
def test_packaged_registry_declares_all_five_providers(self):
|
||||
registry = load_registry()
|
||||
self.assertEqual(
|
||||
tuple(provider.id for provider in registry.providers),
|
||||
_EXPECTED_PROVIDER_IDS,
|
||||
)
|
||||
|
||||
def test_packaged_registry_carries_no_credentials(self):
|
||||
raw = default_registry_path().read_text(encoding="utf-8").lower()
|
||||
for marker in ("token", "password", "secret", "api_key", "credential"):
|
||||
self.assertNotIn(marker, raw)
|
||||
|
||||
|
||||
class TestSeparateEntities(_TempRegistryCase):
|
||||
"""AC: providers and configured workers are separate entities."""
|
||||
|
||||
def test_provider_may_exist_with_no_workers(self):
|
||||
registry = self.parse(
|
||||
_document(providers=[_provider("grok", display_name="Grok")], workers=[])
|
||||
)
|
||||
self.assertEqual(len(registry.providers), 1)
|
||||
self.assertEqual(registry.workers, ())
|
||||
self.assertEqual(workers_for_provider(registry, "grok"), ())
|
||||
|
||||
def test_many_workers_may_share_one_provider(self):
|
||||
registry = self.parse(
|
||||
_document(
|
||||
workers=[
|
||||
_worker("claude-author"),
|
||||
_worker(
|
||||
"claude-reviewer",
|
||||
role="reviewer",
|
||||
namespace="gitea-reviewer",
|
||||
profile="prgs-reviewer",
|
||||
scheduler={"kind": "launchd", "label": "cc.prgs.claude.reviewer"},
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(workers_for_provider(registry, "claude")), 2)
|
||||
self.assertEqual(len(registry.providers), 1)
|
||||
|
||||
def test_worker_referencing_unknown_provider_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[_worker(provider="mystery")]))
|
||||
self.assertIn("unknown provider", str(ctx.exception))
|
||||
|
||||
def test_lookup_helpers(self):
|
||||
registry = self.parse(_document())
|
||||
self.assertIsNotNone(find_worker(registry, "claude-author"))
|
||||
self.assertIsNone(find_worker(registry, "absent"))
|
||||
self.assertIsNotNone(find_provider(registry, "claude"))
|
||||
self.assertIsNone(find_provider(registry, "absent"))
|
||||
|
||||
|
||||
class TestRecordedFields(_TempRegistryCase):
|
||||
"""AC: records provider, model, project, role, namespace/profile, workflow,
|
||||
schedule, timeout, enabled state, and scheduler metadata."""
|
||||
|
||||
def test_every_required_field_is_recorded(self):
|
||||
registry = self.parse(_document())
|
||||
worker = registry.workers[0]
|
||||
self.assertEqual(worker.provider, "claude")
|
||||
self.assertEqual(worker.model, "claude-opus-4-8")
|
||||
self.assertEqual(worker.project, "gitea-tools")
|
||||
self.assertEqual(worker.role, "author")
|
||||
self.assertEqual(worker.namespace, "gitea-author")
|
||||
self.assertEqual(worker.profile, "prgs-author")
|
||||
self.assertEqual(worker.workflow, "skills/llm-project-workflow/workflows/work-issue.md")
|
||||
self.assertEqual(worker.schedule.kind, "cron")
|
||||
self.assertEqual(worker.schedule.expression, "0 * * * *")
|
||||
self.assertEqual(worker.timeout_seconds, 3600)
|
||||
self.assertTrue(worker.enabled)
|
||||
self.assertEqual(worker.scheduler.kind, "launchd")
|
||||
self.assertEqual(worker.scheduler.label, "cc.prgs.claude.author")
|
||||
|
||||
def test_each_required_field_is_individually_required(self):
|
||||
for field in (
|
||||
"provider", "model", "project", "role", "namespace",
|
||||
"profile", "workflow", "schedule", "timeout_seconds",
|
||||
"enabled", "scheduler", "id", "display_name",
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
worker = _worker()
|
||||
worker.pop(field)
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[worker]))
|
||||
|
||||
def test_all_sanctioned_roles_are_accepted(self):
|
||||
for role in ALLOWED_ROLES:
|
||||
with self.subTest(role=role):
|
||||
registry = self.parse(_document(workers=[_worker(role=role)]))
|
||||
self.assertEqual(registry.workers[0].role, role)
|
||||
|
||||
def test_unsanctioned_role_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[_worker(role="admin")]))
|
||||
self.assertIn("role must be one of", str(ctx.exception))
|
||||
|
||||
def test_worker_dict_round_trips_every_field(self):
|
||||
registry = self.parse(_document())
|
||||
encoded = worker_to_dict(registry.workers[0])
|
||||
self.assertEqual(encoded, _worker())
|
||||
json.dumps(encoded) # must stay JSON-safe for the #799 API
|
||||
|
||||
|
||||
class TestSchemaValidation(_TempRegistryCase):
|
||||
"""AC: supports schema validation — and fails closed."""
|
||||
|
||||
def test_unsupported_version_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(version=2))
|
||||
|
||||
def test_root_must_be_an_object(self):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
validate_payload([], source_path=self.path)
|
||||
|
||||
def test_providers_must_be_non_empty(self):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(providers=[]))
|
||||
|
||||
def test_unknown_top_level_field_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(fleet=[]))
|
||||
self.assertIn("unknown fields", str(ctx.exception))
|
||||
|
||||
def test_unknown_worker_field_is_refused_not_ignored(self):
|
||||
# A typo'd field must not be silently dropped: "timeout_second" would
|
||||
# otherwise read as "no timeout declared".
|
||||
worker = _worker()
|
||||
worker["timeout_second"] = 30
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[worker]))
|
||||
self.assertIn("timeout_second", str(ctx.exception))
|
||||
|
||||
def test_credentials_are_refused_anywhere_in_the_document(self):
|
||||
for label, mutate in (
|
||||
("provider.api_token", lambda doc: doc["providers"][0].__setitem__("api_token", "x")),
|
||||
("worker.password", lambda doc: doc["workers"][0].__setitem__("password", "x")),
|
||||
("root.secret", lambda doc: doc.__setitem__("secret", "x")),
|
||||
):
|
||||
with self.subTest(field=label):
|
||||
document = _document()
|
||||
mutate(document)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.parse(document)
|
||||
self.assertIn("credential", str(ctx.exception).lower())
|
||||
|
||||
def test_duplicate_worker_id_is_refused(self):
|
||||
workers = [_worker("dup"), _worker("dup", scheduler={"kind": "manual"})]
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=workers))
|
||||
self.assertIn("duplicate worker id", str(ctx.exception))
|
||||
|
||||
def test_duplicate_provider_id_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(providers=[_provider("claude"), _provider("claude")], workers=[]))
|
||||
self.assertIn("duplicate provider id", str(ctx.exception))
|
||||
|
||||
def test_duplicate_launchagent_label_is_refused(self):
|
||||
# Two workers sharing a label would silently overwrite each other's agent.
|
||||
workers = [
|
||||
_worker("a", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
|
||||
_worker("b", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
|
||||
]
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=workers))
|
||||
self.assertIn("duplicate scheduler label", str(ctx.exception))
|
||||
|
||||
def test_manual_scheduler_needs_no_label_and_many_may_coexist(self):
|
||||
workers = [
|
||||
_worker("a", scheduler={"kind": "manual"}),
|
||||
_worker("b", scheduler={"kind": "manual"}),
|
||||
]
|
||||
registry = self.parse(_document(workers=workers))
|
||||
self.assertEqual([w.scheduler.label for w in registry.workers], [None, None])
|
||||
|
||||
def test_launchd_scheduler_requires_a_label(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[_worker(scheduler={"kind": "launchd"})]))
|
||||
self.assertIn("label is required", str(ctx.exception))
|
||||
|
||||
def test_unknown_scheduler_kind_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(scheduler={"kind": "systemd", "label": "x"})]))
|
||||
|
||||
def test_timeout_must_be_a_positive_bounded_integer(self):
|
||||
for bad in (0, -1, "3600", 1.5, True, 86_401):
|
||||
with self.subTest(timeout=bad):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(timeout_seconds=bad)]))
|
||||
|
||||
def test_enabled_must_be_a_real_boolean(self):
|
||||
for bad in ("true", 1, None):
|
||||
with self.subTest(enabled=bad):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(enabled=bad)]))
|
||||
|
||||
def test_identifier_shape_is_enforced(self):
|
||||
for bad in ("Claude Author", "-leading", "UPPER", ""):
|
||||
with self.subTest(worker_id=bad):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(bad)]))
|
||||
|
||||
|
||||
class TestScheduleValidation(_TempRegistryCase):
|
||||
"""Schedules are declarations; next-run computation belongs to #803."""
|
||||
|
||||
def test_interval_schedule_requires_positive_seconds(self):
|
||||
registry = self.parse(
|
||||
_document(workers=[_worker(schedule={"kind": "interval", "seconds": 900})])
|
||||
)
|
||||
self.assertEqual(registry.workers[0].schedule.seconds, 900)
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(schedule={"kind": "interval"})]))
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(schedule={"kind": "interval", "seconds": 0})]))
|
||||
|
||||
def test_cron_schedule_requires_five_fields(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[_worker(schedule={"kind": "cron", "expression": "0 *"})]))
|
||||
self.assertIn("five crontab fields", str(ctx.exception))
|
||||
|
||||
def test_manual_schedule_needs_no_timing(self):
|
||||
registry = self.parse(_document(workers=[_worker(schedule={"kind": "manual"})]))
|
||||
schedule = registry.workers[0].schedule
|
||||
self.assertEqual(schedule.kind, "manual")
|
||||
self.assertIsNone(schedule.seconds)
|
||||
self.assertIsNone(schedule.expression)
|
||||
|
||||
def test_fields_from_the_wrong_kind_are_refused(self):
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
self.parse(_document(workers=[_worker(schedule={"kind": "manual", "seconds": 60})]))
|
||||
self.assertIn("not valid for kind", str(ctx.exception))
|
||||
|
||||
def test_unknown_schedule_kind_is_refused(self):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(workers=[_worker(schedule={"kind": "hourly"})]))
|
||||
|
||||
|
||||
class TestAtomicPersistence(_TempRegistryCase):
|
||||
"""AC: atomic persistence."""
|
||||
|
||||
def test_save_then_load_round_trips(self):
|
||||
registry = self.parse(_document())
|
||||
save_registry(registry, self.path)
|
||||
reloaded = load_registry(self.path)
|
||||
self.assertEqual(
|
||||
[worker_to_dict(w) for w in reloaded.workers],
|
||||
[worker_to_dict(w) for w in registry.workers],
|
||||
)
|
||||
|
||||
def test_save_leaves_no_temp_files_behind(self):
|
||||
registry = self.parse(_document())
|
||||
save_registry(registry, self.path)
|
||||
save_registry(registry, self.path)
|
||||
leftovers = [p.name for p in self.path.parent.iterdir() if p.name.startswith(".")]
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
def test_save_refuses_to_persist_an_invalid_document(self):
|
||||
registry = self.parse(_document())
|
||||
broken = WorkerRegistry(
|
||||
version=registry.version,
|
||||
revision=registry.revision,
|
||||
updated_at=registry.updated_at,
|
||||
providers=registry.providers,
|
||||
# A worker whose provider is not declared in the registry.
|
||||
workers=tuple(
|
||||
type(worker)(**{**worker.__dict__, "provider": "vanished"})
|
||||
for worker in registry.workers
|
||||
),
|
||||
source_path=self.path,
|
||||
)
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
save_registry(broken, self.path)
|
||||
self.assertFalse(self.path.exists(), "invalid save must not create the file")
|
||||
|
||||
def test_document_shape_excludes_local_paths_but_api_shape_includes_it(self):
|
||||
registry = self.parse(_document())
|
||||
self.assertNotIn("source_path", registry_to_document(registry))
|
||||
self.assertEqual(registry_to_dict(registry)["source_path"], str(self.path))
|
||||
|
||||
|
||||
class TestVersioningAndRollback(_TempRegistryCase):
|
||||
"""AC: versioning and rollback."""
|
||||
|
||||
def _seed(self) -> WorkerRegistry:
|
||||
self.write(_document())
|
||||
return load_registry(self.path)
|
||||
|
||||
def test_revision_increments_on_each_save(self):
|
||||
registry = self._seed()
|
||||
self.assertEqual(registry.revision, 1)
|
||||
second = save_registry(registry, self.path)
|
||||
self.assertEqual(second.revision, 2)
|
||||
third = save_registry(second, self.path)
|
||||
self.assertEqual(third.revision, 3)
|
||||
|
||||
def test_updated_at_is_refreshed_and_utc(self):
|
||||
registry = self._seed()
|
||||
saved = save_registry(registry, self.path)
|
||||
self.assertRegex(saved.updated_at, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
|
||||
def test_superseded_revisions_are_retained(self):
|
||||
registry = self._seed()
|
||||
second = save_registry(registry, self.path)
|
||||
save_registry(second, self.path)
|
||||
self.assertEqual(list_revisions(self.path), (1, 2))
|
||||
self.assertTrue(history_dir(self.path).is_dir())
|
||||
|
||||
def test_rollback_restores_prior_content_as_a_new_revision(self):
|
||||
self.write(_document(workers=[_worker("original")]))
|
||||
registry = load_registry(self.path)
|
||||
|
||||
changed = WorkerRegistry(
|
||||
version=registry.version,
|
||||
revision=registry.revision,
|
||||
updated_at=registry.updated_at,
|
||||
providers=registry.providers,
|
||||
workers=(), # operator deletes every worker
|
||||
source_path=self.path,
|
||||
)
|
||||
save_registry(changed, self.path)
|
||||
self.assertEqual(load_registry(self.path).workers, ())
|
||||
|
||||
restored = rollback_to_revision(1, self.path)
|
||||
self.assertEqual([w.id for w in restored.workers], ["original"])
|
||||
# Append-only: the rollback publishes a new head rather than rewinding.
|
||||
self.assertGreater(restored.revision, 2)
|
||||
self.assertEqual([w.id for w in load_registry(self.path).workers], ["original"])
|
||||
|
||||
def test_rollback_to_unknown_revision_fails_closed(self):
|
||||
self._seed()
|
||||
with self.assertRaises(RegistryValidationError) as ctx:
|
||||
rollback_to_revision(99, self.path)
|
||||
self.assertIn("not retained", str(ctx.exception))
|
||||
|
||||
def test_revision_must_be_a_positive_integer(self):
|
||||
for bad in (0, -1, "1", None):
|
||||
with self.subTest(revision=bad):
|
||||
with self.assertRaises(RegistryValidationError):
|
||||
self.parse(_document(revision=bad))
|
||||
|
||||
def test_history_is_empty_before_any_save(self):
|
||||
self.write(_document())
|
||||
self.assertEqual(list_revisions(self.path), ())
|
||||
|
||||
|
||||
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(
|
||||
_example(
|
||||
"duplicate_canonicalization_blocker",
|
||||
@@ -336,6 +523,49 @@ Who/what acts next:
|
||||
- Required action: implement #507 two-comment validator
|
||||
- Do not do: recreate duplicate CTH issue
|
||||
- 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,57 @@
|
||||
{
|
||||
"version": 1,
|
||||
"revision": 1,
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"providers": [
|
||||
{
|
||||
"id": "claude",
|
||||
"display_name": "Claude",
|
||||
"vendor": "Anthropic",
|
||||
"executable": "claude",
|
||||
"available": true,
|
||||
"models": [
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-4-5-20251001"
|
||||
],
|
||||
"notes": "Model list is a declaration. Live enumeration and version inspection belong to the provider adapter framework (#800)."
|
||||
},
|
||||
{
|
||||
"id": "grok",
|
||||
"display_name": "Grok",
|
||||
"vendor": "xAI",
|
||||
"executable": "grok",
|
||||
"available": true,
|
||||
"models": [],
|
||||
"notes": "Models enumerated by the provider adapter (#800); not declared here."
|
||||
},
|
||||
{
|
||||
"id": "codex",
|
||||
"display_name": "Codex",
|
||||
"vendor": "OpenAI",
|
||||
"executable": "codex",
|
||||
"available": true,
|
||||
"models": [],
|
||||
"notes": "Models enumerated by the provider adapter (#800); not declared here."
|
||||
},
|
||||
{
|
||||
"id": "agy",
|
||||
"display_name": "AGY",
|
||||
"vendor": "Antigravity",
|
||||
"executable": "agy",
|
||||
"available": true,
|
||||
"models": [],
|
||||
"notes": "MCP allowlist gating applies to this provider; confirm server-side allowlist before configuring a worker."
|
||||
},
|
||||
{
|
||||
"id": "kimi-k",
|
||||
"display_name": "Kimi K",
|
||||
"vendor": "Moonshot AI",
|
||||
"executable": "kimi",
|
||||
"available": true,
|
||||
"models": [],
|
||||
"notes": "Provider id is kimi-k; the executable on PATH is kimi. Models enumerated by the provider adapter (#800)."
|
||||
}
|
||||
],
|
||||
"workers": []
|
||||
}
|
||||
@@ -8,27 +8,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_FORBIDDEN_EXACT_KEYS = frozenset({
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"auth",
|
||||
"api_key",
|
||||
"api-key",
|
||||
})
|
||||
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
||||
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
||||
|
||||
|
||||
def _is_forbidden_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
if lowered in _FORBIDDEN_EXACT_KEYS:
|
||||
return True
|
||||
return (
|
||||
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
||||
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
||||
)
|
||||
from webui.registry_safety import reject_credential_keys as _reject_credential_keys
|
||||
|
||||
_REQUIRED_PROJECT_FIELDS = (
|
||||
"id",
|
||||
@@ -79,18 +59,6 @@ def default_registry_path() -> Path:
|
||||
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
||||
|
||||
|
||||
def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
key_path = f"{path}.{key}" if path else key
|
||||
if _is_forbidden_key(key):
|
||||
raise ValueError(f"registry must not store credentials ({key_path})")
|
||||
_reject_credential_keys(value, path=key_path)
|
||||
elif isinstance(obj, list):
|
||||
for index, item in enumerate(obj):
|
||||
_reject_credential_keys(item, path=f"{path}[{index}]")
|
||||
|
||||
|
||||
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
|
||||
if not raw:
|
||||
return ()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Shared credential-rejection guard for web UI registries (#427, #798).
|
||||
|
||||
Registries are operator-editable declarative files that the web UI loads and,
|
||||
for the worker registry, writes back. None of them may ever carry a secret:
|
||||
credentials belong in the keychain and reach worker processes through
|
||||
environment injection, never through a file the browser layer can read.
|
||||
|
||||
The check is structural rather than value-based on purpose. A value scanner has
|
||||
to guess what a secret looks like; a key scanner refuses the *shape* of a
|
||||
credential field, so an operator cannot introduce one by accident and a later
|
||||
loader cannot silently pass one through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_FORBIDDEN_EXACT_KEYS = frozenset({
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"auth",
|
||||
"api_key",
|
||||
"api-key",
|
||||
})
|
||||
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
||||
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
||||
|
||||
|
||||
def is_forbidden_key(key: str) -> bool:
|
||||
"""Return True when *key* names a credential field."""
|
||||
lowered = key.lower()
|
||||
if lowered in _FORBIDDEN_EXACT_KEYS:
|
||||
return True
|
||||
return (
|
||||
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
||||
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
||||
)
|
||||
|
||||
|
||||
def reject_credential_keys(obj: Any, *, path: str = "", subject: str = "registry") -> None:
|
||||
"""Raise ValueError when *obj* carries a credential-shaped key at any depth."""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
key_path = f"{path}.{key}" if path else key
|
||||
if is_forbidden_key(key):
|
||||
raise ValueError(f"{subject} must not store credentials ({key_path})")
|
||||
reject_credential_keys(value, path=key_path, subject=subject)
|
||||
elif isinstance(obj, list):
|
||||
for index, item in enumerate(obj):
|
||||
reject_credential_keys(item, path=f"{path}[{index}]", subject=subject)
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Declarative worker registry and configuration schema (#798, epic #797).
|
||||
|
||||
The registry is the single source of truth for the scheduled multi-LLM worker
|
||||
fleet. It is a versioned JSON document holding two *separate* entity kinds:
|
||||
|
||||
* **Providers** — the LLM runtimes a worker can be built on (Claude, Grok,
|
||||
Codex, AGY, Kimi K). A provider describes the runtime itself: vendor,
|
||||
executable name, models it can serve, and whether it is available on this
|
||||
machine. Providers exist whether or not any worker uses them.
|
||||
* **Workers** — a configured *instance*: one provider, one model, one project,
|
||||
one role, one MCP namespace/profile, one workflow, one schedule. Several
|
||||
workers may share a provider; a worker naming an undeclared provider is
|
||||
refused.
|
||||
|
||||
Keeping them separate is what lets #799 list all five providers even when a
|
||||
provider currently has no configured worker, and it stops provider facts from
|
||||
being copied into (and drifting across) every worker record.
|
||||
|
||||
Scope boundary. This module owns the data model, its validation, and its
|
||||
persistence. It does **not** schedule anything, launch anything, probe provider
|
||||
executables, or serve HTTP. Loading a registry never touches a process; the
|
||||
live fields a dashboard wants (PID, elapsed time, next run) are derived
|
||||
elsewhere (#799, #801, #803, #804) from these declarations.
|
||||
|
||||
Safety invariants:
|
||||
|
||||
* No credential may be stored (:mod:`webui.registry_safety`), so the registry
|
||||
stays safe to render and to hand to a browser layer.
|
||||
* Validation fails closed. Unknown fields are refused rather than ignored, so a
|
||||
typo cannot silently disable a timeout or a role binding.
|
||||
* Writes are atomic and every superseded document is retained as a numbered
|
||||
revision, so a bad edit is recoverable by rollback rather than hand-repair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from webui.registry_safety import reject_credential_keys
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
#: Roles a worker may hold. These mirror the sanctioned MCP role kinds; a
|
||||
#: worker may not invent one, because the role selects the namespace/profile
|
||||
#: whose capability gates constrain it.
|
||||
ALLOWED_ROLES = ("author", "reviewer", "merger", "reconciler", "cleanup")
|
||||
|
||||
#: Scheduler backends the registry can describe. ``manual`` means the worker is
|
||||
#: only ever started on request and has no recurring trigger.
|
||||
ALLOWED_SCHEDULER_KINDS = ("launchd", "manual")
|
||||
|
||||
#: Schedule kinds. Next-run computation belongs to #803; this module only
|
||||
#: guarantees the declaration is well formed.
|
||||
ALLOWED_SCHEDULE_KINDS = ("interval", "cron", "manual")
|
||||
|
||||
_REQUIRED_PROVIDER_FIELDS = ("id", "display_name", "vendor", "executable", "available")
|
||||
_OPTIONAL_PROVIDER_FIELDS = ("models", "notes")
|
||||
|
||||
_REQUIRED_WORKER_FIELDS = (
|
||||
"id",
|
||||
"display_name",
|
||||
"provider",
|
||||
"model",
|
||||
"project",
|
||||
"role",
|
||||
"namespace",
|
||||
"profile",
|
||||
"workflow",
|
||||
"schedule",
|
||||
"timeout_seconds",
|
||||
"enabled",
|
||||
"scheduler",
|
||||
)
|
||||
_OPTIONAL_WORKER_FIELDS = ("notes",)
|
||||
|
||||
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
#: Guards against an operator writing a timeout that would let a worker hold a
|
||||
#: lease effectively forever. 24h is far above any sanctioned cycle.
|
||||
_MAX_TIMEOUT_SECONDS = 86_400
|
||||
|
||||
#: How many superseded revisions to retain beside the live file.
|
||||
_HISTORY_LIMIT = 20
|
||||
|
||||
_TOP_LEVEL_FIELDS = frozenset({"version", "revision", "updated_at", "providers", "workers"})
|
||||
|
||||
|
||||
class RegistryValidationError(ValueError):
|
||||
"""Raised when a registry document violates the schema."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRecord:
|
||||
id: str
|
||||
display_name: str
|
||||
vendor: str
|
||||
executable: str
|
||||
available: bool
|
||||
models: tuple[str, ...]
|
||||
notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleSpec:
|
||||
kind: str
|
||||
#: Set for ``interval`` schedules.
|
||||
seconds: int | None
|
||||
#: Set for ``cron`` schedules — a five-field crontab expression.
|
||||
expression: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerSpec:
|
||||
kind: str
|
||||
#: LaunchAgent label; required for ``launchd``, absent for ``manual``.
|
||||
label: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerRecord:
|
||||
id: str
|
||||
display_name: str
|
||||
provider: str
|
||||
model: str
|
||||
project: str
|
||||
role: str
|
||||
namespace: str
|
||||
profile: str
|
||||
workflow: str
|
||||
schedule: ScheduleSpec
|
||||
timeout_seconds: int
|
||||
enabled: bool
|
||||
scheduler: SchedulerSpec
|
||||
notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerRegistry:
|
||||
version: int
|
||||
revision: int
|
||||
updated_at: str
|
||||
providers: tuple[ProviderRecord, ...]
|
||||
workers: tuple[WorkerRecord, ...]
|
||||
source_path: Path
|
||||
|
||||
|
||||
# ── paths ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def default_registry_path() -> Path:
|
||||
"""Location of the packaged worker registry, overridable for tests/deploys."""
|
||||
override = os.environ.get("WEBUI_WORKER_REGISTRY", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return (Path(__file__).resolve().parent / "data" / "workers.registry.json").resolve()
|
||||
|
||||
|
||||
def history_dir(path: Path | None = None) -> Path:
|
||||
"""Directory holding superseded revisions of *path*."""
|
||||
source = (path or default_registry_path()).resolve()
|
||||
return source.parent / f"{source.name}.history"
|
||||
|
||||
|
||||
# ── field helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_exact_fields(
|
||||
raw: Any,
|
||||
*,
|
||||
required: tuple[str, ...],
|
||||
optional: tuple[str, ...],
|
||||
subject: str,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise RegistryValidationError(f"{subject} must be an object")
|
||||
missing = [field for field in required if field not in raw]
|
||||
if missing:
|
||||
raise RegistryValidationError(
|
||||
f"{subject} missing required fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
unknown = sorted(set(raw) - set(required) - set(optional))
|
||||
if unknown:
|
||||
# Fail closed: silently dropping an unrecognized key is how a typo'd
|
||||
# "timeout_second" ends up meaning "no timeout".
|
||||
raise RegistryValidationError(f"{subject} has unknown fields: {', '.join(unknown)}")
|
||||
return raw
|
||||
|
||||
|
||||
def _require_identifier(value: Any, *, subject: str) -> str:
|
||||
text = str(value).strip()
|
||||
if not _ID_RE.match(text):
|
||||
raise RegistryValidationError(
|
||||
f"{subject} must be lowercase alphanumeric with '.', '_', or '-' (got {value!r})"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _require_text(value: Any, *, subject: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise RegistryValidationError(f"{subject} must be a string (got {value!r})")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise RegistryValidationError(f"{subject} must be a non-empty string")
|
||||
return text
|
||||
|
||||
|
||||
def _require_bool(value: Any, *, subject: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise RegistryValidationError(f"{subject} must be a boolean (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _require_positive_int(value: Any, *, subject: str, maximum: int | None = None) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise RegistryValidationError(f"{subject} must be an integer (got {value!r})")
|
||||
if value <= 0:
|
||||
raise RegistryValidationError(f"{subject} must be greater than zero (got {value})")
|
||||
if maximum is not None and value > maximum:
|
||||
raise RegistryValidationError(f"{subject} must not exceed {maximum} (got {value})")
|
||||
return value
|
||||
|
||||
|
||||
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_provider(raw: Any) -> ProviderRecord:
|
||||
data = _require_exact_fields(
|
||||
raw,
|
||||
required=_REQUIRED_PROVIDER_FIELDS,
|
||||
optional=_OPTIONAL_PROVIDER_FIELDS,
|
||||
subject="provider",
|
||||
)
|
||||
provider_id = _require_identifier(data["id"], subject="provider.id")
|
||||
|
||||
models_raw = data.get("models") or []
|
||||
if not isinstance(models_raw, list):
|
||||
raise RegistryValidationError(f"provider[{provider_id}].models must be an array")
|
||||
models = tuple(
|
||||
_require_text(item, subject=f"provider[{provider_id}].models[]") for item in models_raw
|
||||
)
|
||||
|
||||
return ProviderRecord(
|
||||
id=provider_id,
|
||||
display_name=_require_text(
|
||||
data["display_name"], subject=f"provider[{provider_id}].display_name"
|
||||
),
|
||||
vendor=_require_text(data["vendor"], subject=f"provider[{provider_id}].vendor"),
|
||||
executable=_require_text(data["executable"], subject=f"provider[{provider_id}].executable"),
|
||||
available=_require_bool(data["available"], subject=f"provider[{provider_id}].available"),
|
||||
models=models,
|
||||
notes=str(data.get("notes") or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_schedule(raw: Any, *, subject: str) -> ScheduleSpec:
|
||||
if not isinstance(raw, dict):
|
||||
raise RegistryValidationError(f"{subject} must be an object")
|
||||
kind = _require_text(raw.get("kind"), subject=f"{subject}.kind")
|
||||
if kind not in ALLOWED_SCHEDULE_KINDS:
|
||||
raise RegistryValidationError(
|
||||
f"{subject}.kind must be one of {', '.join(ALLOWED_SCHEDULE_KINDS)} (got {kind!r})"
|
||||
)
|
||||
|
||||
seconds: int | None = None
|
||||
expression: str | None = None
|
||||
|
||||
if kind == "interval":
|
||||
if "seconds" not in raw:
|
||||
raise RegistryValidationError(f"{subject}.seconds is required for interval schedules")
|
||||
seconds = _require_positive_int(raw["seconds"], subject=f"{subject}.seconds")
|
||||
elif kind == "cron":
|
||||
if "expression" not in raw:
|
||||
raise RegistryValidationError(f"{subject}.expression is required for cron schedules")
|
||||
expression = _require_text(raw["expression"], subject=f"{subject}.expression")
|
||||
if len(expression.split()) != 5:
|
||||
raise RegistryValidationError(
|
||||
f"{subject}.expression must have five crontab fields (got {expression!r})"
|
||||
)
|
||||
|
||||
allowed = {"kind"}
|
||||
if kind == "interval":
|
||||
allowed.add("seconds")
|
||||
elif kind == "cron":
|
||||
allowed.add("expression")
|
||||
unknown = sorted(set(raw) - allowed)
|
||||
if unknown:
|
||||
raise RegistryValidationError(
|
||||
f"{subject} has fields not valid for kind {kind!r}: {', '.join(unknown)}"
|
||||
)
|
||||
|
||||
return ScheduleSpec(kind=kind, seconds=seconds, expression=expression)
|
||||
|
||||
|
||||
def _parse_scheduler(raw: Any, *, subject: str) -> SchedulerSpec:
|
||||
if not isinstance(raw, dict):
|
||||
raise RegistryValidationError(f"{subject} must be an object")
|
||||
kind = _require_text(raw.get("kind"), subject=f"{subject}.kind")
|
||||
if kind not in ALLOWED_SCHEDULER_KINDS:
|
||||
raise RegistryValidationError(
|
||||
f"{subject}.kind must be one of {', '.join(ALLOWED_SCHEDULER_KINDS)} (got {kind!r})"
|
||||
)
|
||||
|
||||
label: str | None = None
|
||||
if kind == "launchd":
|
||||
if "label" not in raw:
|
||||
raise RegistryValidationError(f"{subject}.label is required for launchd schedulers")
|
||||
label = _require_text(raw["label"], subject=f"{subject}.label")
|
||||
|
||||
allowed = {"kind"}
|
||||
if kind == "launchd":
|
||||
allowed.add("label")
|
||||
unknown = sorted(set(raw) - allowed)
|
||||
if unknown:
|
||||
raise RegistryValidationError(
|
||||
f"{subject} has fields not valid for kind {kind!r}: {', '.join(unknown)}"
|
||||
)
|
||||
|
||||
return SchedulerSpec(kind=kind, label=label)
|
||||
|
||||
|
||||
def _parse_worker(raw: Any) -> WorkerRecord:
|
||||
data = _require_exact_fields(
|
||||
raw,
|
||||
required=_REQUIRED_WORKER_FIELDS,
|
||||
optional=_OPTIONAL_WORKER_FIELDS,
|
||||
subject="worker",
|
||||
)
|
||||
worker_id = _require_identifier(data["id"], subject="worker.id")
|
||||
|
||||
role = _require_text(data["role"], subject=f"worker[{worker_id}].role")
|
||||
if role not in ALLOWED_ROLES:
|
||||
raise RegistryValidationError(
|
||||
f"worker[{worker_id}].role must be one of {', '.join(ALLOWED_ROLES)} (got {role!r})"
|
||||
)
|
||||
|
||||
return WorkerRecord(
|
||||
id=worker_id,
|
||||
display_name=_require_text(
|
||||
data["display_name"], subject=f"worker[{worker_id}].display_name"
|
||||
),
|
||||
provider=_require_identifier(data["provider"], subject=f"worker[{worker_id}].provider"),
|
||||
model=_require_text(data["model"], subject=f"worker[{worker_id}].model"),
|
||||
project=_require_text(data["project"], subject=f"worker[{worker_id}].project"),
|
||||
role=role,
|
||||
namespace=_require_text(data["namespace"], subject=f"worker[{worker_id}].namespace"),
|
||||
profile=_require_text(data["profile"], subject=f"worker[{worker_id}].profile"),
|
||||
workflow=_require_text(data["workflow"], subject=f"worker[{worker_id}].workflow"),
|
||||
schedule=_parse_schedule(data["schedule"], subject=f"worker[{worker_id}].schedule"),
|
||||
timeout_seconds=_require_positive_int(
|
||||
data["timeout_seconds"],
|
||||
subject=f"worker[{worker_id}].timeout_seconds",
|
||||
maximum=_MAX_TIMEOUT_SECONDS,
|
||||
),
|
||||
enabled=_require_bool(data["enabled"], subject=f"worker[{worker_id}].enabled"),
|
||||
scheduler=_parse_scheduler(data["scheduler"], subject=f"worker[{worker_id}].scheduler"),
|
||||
notes=str(data.get("notes") or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _require_unique(values: list[str], *, subject: str) -> None:
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if value in seen:
|
||||
raise RegistryValidationError(f"duplicate {subject}: {value}")
|
||||
seen.add(value)
|
||||
|
||||
|
||||
def validate_payload(payload: Any, *, source_path: Path) -> WorkerRegistry:
|
||||
"""Validate a decoded registry document and return the typed registry.
|
||||
|
||||
Raises :class:`RegistryValidationError` on any violation; never partially
|
||||
accepts a document.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise RegistryValidationError("registry root must be an object")
|
||||
|
||||
version = payload.get("version")
|
||||
if version != SCHEMA_VERSION:
|
||||
raise RegistryValidationError(f"unsupported registry version: {version!r}")
|
||||
|
||||
reject_credential_keys(payload, subject="worker registry")
|
||||
|
||||
unknown = sorted(set(payload) - _TOP_LEVEL_FIELDS)
|
||||
if unknown:
|
||||
raise RegistryValidationError(f"registry has unknown fields: {', '.join(unknown)}")
|
||||
|
||||
revision = _require_positive_int(payload.get("revision"), subject="revision")
|
||||
updated_at = _require_text(payload.get("updated_at"), subject="updated_at")
|
||||
|
||||
providers_raw = payload.get("providers")
|
||||
if not isinstance(providers_raw, list) or not providers_raw:
|
||||
raise RegistryValidationError("providers must be a non-empty array")
|
||||
providers = tuple(_parse_provider(item) for item in providers_raw)
|
||||
_require_unique([provider.id for provider in providers], subject="provider id")
|
||||
|
||||
workers_raw = payload.get("workers")
|
||||
if not isinstance(workers_raw, list):
|
||||
raise RegistryValidationError("workers must be an array")
|
||||
workers = tuple(_parse_worker(item) for item in workers_raw)
|
||||
_require_unique([worker.id for worker in workers], subject="worker id")
|
||||
|
||||
# Referential integrity: a worker naming an undeclared provider would look
|
||||
# configured while being unrunnable, which is exactly the ambiguous
|
||||
# ownership the epic requires to fail closed.
|
||||
known_providers = {provider.id for provider in providers}
|
||||
for worker in workers:
|
||||
if worker.provider not in known_providers:
|
||||
raise RegistryValidationError(
|
||||
f"worker[{worker.id}].provider references unknown provider {worker.provider!r}"
|
||||
)
|
||||
|
||||
# A LaunchAgent label identifies a job to launchd; two workers sharing one
|
||||
# would silently overwrite each other's agent.
|
||||
_require_unique(
|
||||
[worker.scheduler.label for worker in workers if worker.scheduler.label],
|
||||
subject="scheduler label",
|
||||
)
|
||||
|
||||
return WorkerRegistry(
|
||||
version=version,
|
||||
revision=revision,
|
||||
updated_at=updated_at,
|
||||
providers=providers,
|
||||
workers=workers,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> WorkerRegistry:
|
||||
"""Load and validate the worker registry from disk."""
|
||||
source = (path or default_registry_path()).resolve()
|
||||
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||
return validate_payload(payload, source_path=source)
|
||||
|
||||
|
||||
# ── serialization ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def provider_to_dict(provider: ProviderRecord) -> dict[str, Any]:
|
||||
return {
|
||||
"id": provider.id,
|
||||
"display_name": provider.display_name,
|
||||
"vendor": provider.vendor,
|
||||
"executable": provider.executable,
|
||||
"available": provider.available,
|
||||
"models": list(provider.models),
|
||||
"notes": provider.notes,
|
||||
}
|
||||
|
||||
|
||||
def _schedule_to_dict(schedule: ScheduleSpec) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"kind": schedule.kind}
|
||||
if schedule.kind == "interval":
|
||||
payload["seconds"] = schedule.seconds
|
||||
elif schedule.kind == "cron":
|
||||
payload["expression"] = schedule.expression
|
||||
return payload
|
||||
|
||||
|
||||
def _scheduler_to_dict(scheduler: SchedulerSpec) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"kind": scheduler.kind}
|
||||
if scheduler.kind == "launchd":
|
||||
payload["label"] = scheduler.label
|
||||
return payload
|
||||
|
||||
|
||||
def worker_to_dict(worker: WorkerRecord) -> dict[str, Any]:
|
||||
return {
|
||||
"id": worker.id,
|
||||
"display_name": worker.display_name,
|
||||
"provider": worker.provider,
|
||||
"model": worker.model,
|
||||
"project": worker.project,
|
||||
"role": worker.role,
|
||||
"namespace": worker.namespace,
|
||||
"profile": worker.profile,
|
||||
"workflow": worker.workflow,
|
||||
"schedule": _schedule_to_dict(worker.schedule),
|
||||
"timeout_seconds": worker.timeout_seconds,
|
||||
"enabled": worker.enabled,
|
||||
"scheduler": _scheduler_to_dict(worker.scheduler),
|
||||
"notes": worker.notes,
|
||||
}
|
||||
|
||||
|
||||
def registry_to_document(registry: WorkerRegistry) -> dict[str, Any]:
|
||||
"""Serialize to the on-disk document shape (no local paths embedded)."""
|
||||
return {
|
||||
"version": registry.version,
|
||||
"revision": registry.revision,
|
||||
"updated_at": registry.updated_at,
|
||||
"providers": [provider_to_dict(provider) for provider in registry.providers],
|
||||
"workers": [worker_to_dict(worker) for worker in registry.workers],
|
||||
}
|
||||
|
||||
|
||||
def registry_to_dict(registry: WorkerRegistry) -> dict[str, Any]:
|
||||
"""Serialize for JSON API responses (adds the resolved source path)."""
|
||||
document = registry_to_document(registry)
|
||||
document["source_path"] = str(registry.source_path)
|
||||
return document
|
||||
|
||||
|
||||
def find_worker(registry: WorkerRegistry, worker_id: str) -> WorkerRecord | None:
|
||||
for worker in registry.workers:
|
||||
if worker.id == worker_id:
|
||||
return worker
|
||||
return None
|
||||
|
||||
|
||||
def find_provider(registry: WorkerRegistry, provider_id: str) -> ProviderRecord | None:
|
||||
for provider in registry.providers:
|
||||
if provider.id == provider_id:
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
def workers_for_provider(registry: WorkerRegistry, provider_id: str) -> tuple[WorkerRecord, ...]:
|
||||
return tuple(worker for worker in registry.workers if worker.provider == provider_id)
|
||||
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: str) -> None:
|
||||
"""Write *payload* to *path* atomically: temp file in the same dir, fsync, replace."""
|
||||
parent = path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=f".{path.name}-", suffix=".tmp", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _revision_path(directory: Path, revision: int) -> Path:
|
||||
return directory / f"rev-{revision:06d}.json"
|
||||
|
||||
|
||||
def _prune_history(path: Path) -> None:
|
||||
directory = history_dir(path)
|
||||
revisions = list_revisions(path)
|
||||
excess = len(revisions) - _HISTORY_LIMIT
|
||||
for revision in revisions[: max(0, excess)]:
|
||||
_revision_path(directory, revision).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _archive_current(path: Path) -> int | None:
|
||||
"""Copy the live document into the history dir under its own revision number."""
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
revision = int(existing.get("revision", 0))
|
||||
except (json.JSONDecodeError, TypeError, ValueError, AttributeError):
|
||||
# An unreadable live file has no trustworthy revision number to file it
|
||||
# under, so it cannot join the history chain.
|
||||
return None
|
||||
if revision <= 0:
|
||||
return None
|
||||
_atomic_write(
|
||||
_revision_path(history_dir(path), revision),
|
||||
json.dumps(existing, indent=2, sort_keys=True) + "\n",
|
||||
)
|
||||
_prune_history(path)
|
||||
return revision
|
||||
|
||||
|
||||
def list_revisions(path: Path | None = None) -> tuple[int, ...]:
|
||||
"""Revision numbers retained in history for *path*, oldest first."""
|
||||
directory = history_dir(path)
|
||||
if not directory.is_dir():
|
||||
return ()
|
||||
revisions: list[int] = []
|
||||
for entry in directory.glob("rev-*.json"):
|
||||
try:
|
||||
revisions.append(int(entry.stem.split("-", 1)[1]))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
return tuple(sorted(revisions))
|
||||
|
||||
|
||||
def save_registry(
|
||||
registry: WorkerRegistry,
|
||||
path: Path | None = None,
|
||||
*,
|
||||
updated_at: str | None = None,
|
||||
) -> WorkerRegistry:
|
||||
"""Validate, archive the superseded revision, then atomically persist a new one.
|
||||
|
||||
The stored revision is always the previous revision plus one, so a reader
|
||||
can tell two documents apart even when their content is otherwise equal.
|
||||
Returns the registry exactly as persisted.
|
||||
"""
|
||||
target = (path or registry.source_path or default_registry_path()).resolve()
|
||||
|
||||
document = registry_to_document(registry)
|
||||
# Re-validate before writing: a registry assembled in memory has not
|
||||
# necessarily been through the loader.
|
||||
validate_payload(document, source_path=target)
|
||||
|
||||
archived = _archive_current(target)
|
||||
document["revision"] = (archived + 1) if archived is not None else registry.revision
|
||||
document["updated_at"] = updated_at or _utc_now()
|
||||
|
||||
persisted = validate_payload(document, source_path=target)
|
||||
_atomic_write(target, json.dumps(document, indent=2, sort_keys=True) + "\n")
|
||||
return persisted
|
||||
|
||||
|
||||
def rollback_to_revision(revision: int, path: Path | None = None) -> WorkerRegistry:
|
||||
"""Restore a retained *revision* as a new head revision.
|
||||
|
||||
History is append-only: rolling back does not delete the revisions in
|
||||
between, it republishes the chosen one under the next revision number, so a
|
||||
rollback is itself reversible.
|
||||
"""
|
||||
target = (path or default_registry_path()).resolve()
|
||||
snapshot_path = _revision_path(history_dir(target), revision)
|
||||
if not snapshot_path.exists():
|
||||
available = ", ".join(str(item) for item in list_revisions(target)) or "(none)"
|
||||
raise RegistryValidationError(
|
||||
f"revision {revision} is not retained for {target.name}; available: {available}"
|
||||
)
|
||||
|
||||
payload = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
restored = validate_payload(payload, source_path=target)
|
||||
return save_registry(restored, target)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user