Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2fc2683b9 | ||
|
|
889931d553 |
+221
-346
@@ -195,6 +195,12 @@ RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import mcp_namespace_health # noqa: E402
|
||||
import stale_binding_recovery # noqa: E402
|
||||
|
||||
# Worktree env bindings inherited from the parent environment at daemon boot
|
||||
# (#702). A value that still equals its boot snapshot was never established by
|
||||
# a sanctioned tool in this daemon's lifetime.
|
||||
_BOOT_ENV_WORKTREE_BINDINGS = stale_binding_recovery.snapshot_boot_bindings()
|
||||
|
||||
|
||||
def _preflight_in_test_mode() -> bool:
|
||||
@@ -260,8 +266,124 @@ def _actual_profile_role() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _assess_stale_active_binding(auto_recover: bool = False) -> dict:
|
||||
"""Classify the GITEA_ACTIVE_WORKTREE binding; recover when provably stale (#702).
|
||||
|
||||
Runs during ``gitea_resolve_task_capability`` / runtime context. Clearing
|
||||
is the sanctioned in-daemon mechanism and happens only for the two
|
||||
provably-stale classes (missing path, superseded by a sanctioned session
|
||||
lease); an uncorroborated boot-inherited binding is surfaced with the
|
||||
managed-reconnect next action, never cleared silently.
|
||||
"""
|
||||
active_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||
try:
|
||||
role = _actual_profile_role()
|
||||
except Exception:
|
||||
role = "author"
|
||||
role_env_key = nwb.ROLE_WORKTREE_ENVS.get(role, AUTHOR_WORKTREE_ENV)
|
||||
boot_value = (
|
||||
(_BOOT_ENV_WORKTREE_BINDINGS.get("active_worktree") or "").strip() or None
|
||||
)
|
||||
classification = stale_binding_recovery.classify_active_worktree_binding(
|
||||
active_value=active_value,
|
||||
role_env_value=(os.environ.get(role_env_key) or "").strip() or None,
|
||||
session_lease_worktree=_reviewer_session_worktree(),
|
||||
boot_inherited=bool(active_value and boot_value == active_value),
|
||||
path_exists=os.path.isdir(active_value) if active_value else None,
|
||||
role_kind=role,
|
||||
)
|
||||
plan = stale_binding_recovery.plan_recovery(classification)
|
||||
applied: dict = {"performed": False}
|
||||
recovery_enabled = (
|
||||
not _preflight_in_test_mode()
|
||||
or os.environ.get("GITEA_FORCE_STALE_BINDING_RECOVERY") == "1"
|
||||
)
|
||||
audit_persisted: bool | None = None
|
||||
if auto_recover and plan.get("clear_allowed") and recovery_enabled:
|
||||
# #702 F6: the durable audit record must exist BEFORE the environment
|
||||
# is cleared. If audit persistence fails the recovery does not run —
|
||||
# an env clear without durable proof is an unauditable mutation.
|
||||
pending_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||
audit_error: str | None = None
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
mss.save_state(
|
||||
kind=mss.KIND_STALE_BINDING_RECOVERY,
|
||||
payload={
|
||||
"cleared_env": ACTIVE_WORKTREE_ENV,
|
||||
"cleared_value": pending_value,
|
||||
"classification": plan.get("classification"),
|
||||
"reasons": list(plan.get("reasons") or []),
|
||||
"recovery_status": "pending",
|
||||
},
|
||||
)
|
||||
audit_persisted = True
|
||||
except Exception as exc:
|
||||
audit_persisted = False
|
||||
audit_error = f"{type(exc).__name__}: {exc}"
|
||||
if audit_persisted:
|
||||
applied = stale_binding_recovery.apply_recovery(plan)
|
||||
if applied.get("performed"):
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
mss.save_state(
|
||||
kind=mss.KIND_STALE_BINDING_RECOVERY,
|
||||
payload={
|
||||
"cleared_env": applied.get("cleared_env"),
|
||||
"cleared_value": applied.get("cleared_value"),
|
||||
"classification": applied.get("classification"),
|
||||
"reasons": applied.get("reasons"),
|
||||
"recovery_status": "performed",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# The pre-clear "pending" record is already durable; the
|
||||
# status refresh is best-effort and never rolls back.
|
||||
pass
|
||||
else:
|
||||
applied = {
|
||||
"performed": False,
|
||||
"action": stale_binding_recovery.RECOVERY_ACTION_NONE,
|
||||
"audit_write_failed": True,
|
||||
"reasons": [
|
||||
"audit persistence failed; stale binding NOT cleared "
|
||||
"(fail closed, #702 F6)"
|
||||
]
|
||||
+ ([audit_error] if audit_error else []),
|
||||
}
|
||||
report = {
|
||||
"classification": classification.get("classification"),
|
||||
"active_worktree": classification.get("active_worktree"),
|
||||
"boot_inherited": classification.get("boot_inherited", False),
|
||||
"clear_eligible": bool(classification.get("clear_eligible")),
|
||||
"recovery_action": plan.get("action"),
|
||||
"recovery_performed": bool(applied.get("performed")),
|
||||
"reasons": classification.get("reasons") or [],
|
||||
}
|
||||
if audit_persisted is not None:
|
||||
report["audit_persisted"] = audit_persisted
|
||||
if applied.get("audit_write_failed"):
|
||||
report["audit_write_failed"] = True
|
||||
report["reasons"] = list(report["reasons"]) + list(
|
||||
applied.get("reasons") or []
|
||||
)
|
||||
if applied.get("performed"):
|
||||
report["cleared_value"] = applied.get("cleared_value")
|
||||
if plan.get("exact_next_action"):
|
||||
report["exact_next_action"] = plan.get("exact_next_action")
|
||||
return report
|
||||
|
||||
|
||||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards."""
|
||||
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards.
|
||||
|
||||
#702 F2: preflight resolves with the same existence verification as the
|
||||
canonical mutation context (verify_paths). A dead env binding must demote
|
||||
here exactly as it does in :func:`_resolve_namespace_mutation_context`,
|
||||
or preflight would inspect a path the mutation guard never uses.
|
||||
"""
|
||||
role = _effective_workspace_role()
|
||||
workspace, _source = nwb.resolve_namespace_workspace(
|
||||
role_kind=role,
|
||||
@@ -271,6 +393,7 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||||
),
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
verify_paths=True,
|
||||
)
|
||||
return workspace
|
||||
|
||||
@@ -317,6 +440,15 @@ def _get_git_root(path: str) -> str | None:
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
# #702 F2: synthetic tracked-dirty porcelain returned when the preflight
|
||||
# workspace cannot be inspected (missing path, git failure). Shaped as a
|
||||
# modified tracked entry so every consumer treats "workspace unavailable"
|
||||
# as dirty — an uninspectable workspace must never read as clean.
|
||||
PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN = (
|
||||
" M __gitea_preflight_workspace_unavailable__\n"
|
||||
)
|
||||
|
||||
|
||||
def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
|
||||
"""Return tracked-workspace porcelain for pre-flight attribution."""
|
||||
if os.environ.get("GITEA_TEST_FORCE_DIRTY"):
|
||||
@@ -325,6 +457,8 @@ def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
|
||||
if override is not None:
|
||||
return override
|
||||
workspace = _resolve_preflight_workspace_path(worktree_path)
|
||||
if not os.path.isdir(workspace):
|
||||
return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
@@ -332,9 +466,11 @@ def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
|
||||
text=True,
|
||||
cwd=workspace,
|
||||
)
|
||||
return res.stdout or ""
|
||||
except Exception:
|
||||
return ""
|
||||
return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
if res.returncode != 0:
|
||||
return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
return res.stdout or ""
|
||||
|
||||
|
||||
def _parse_porcelain_entries(porcelain: str) -> dict[str, str]:
|
||||
@@ -3338,8 +3474,6 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
"live_mutations": [],
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
"correction_pr_number": None,
|
||||
"correction_head_sha": None,
|
||||
})
|
||||
|
||||
|
||||
@@ -3424,30 +3558,21 @@ def check_review_decision_gate(
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||
):
|
||||
scoped = stale_review_decision_lock.correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||
)
|
||||
if prior and not scoped:
|
||||
if prior and not lock.get("correction_authorized"):
|
||||
reasons.append(
|
||||
"live review mutation already recorded for this PR head in this "
|
||||
"run; only one live review mutation is allowed per head unless "
|
||||
"gitea_authorize_review_correction was invoked for this same "
|
||||
"PR/head (fail closed, #620/#693)"
|
||||
"gitea_authorize_review_correction was invoked (fail closed, #620)"
|
||||
)
|
||||
elif (
|
||||
action in _TERMINAL_REVIEW_ACTIONS
|
||||
and any(
|
||||
isinstance(m, dict)
|
||||
and m.get("action") in _TERMINAL_REVIEW_ACTIONS
|
||||
and m.get("pr_number") == pr_number
|
||||
for m in prior
|
||||
)
|
||||
and not scoped
|
||||
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
|
||||
and not lock.get("correction_authorized")
|
||||
):
|
||||
reasons.append(
|
||||
"terminal review decision already submitted for this PR head in "
|
||||
"this run; blocked unless an operator-approved correction was "
|
||||
"authorized for this same PR/head (fail closed, #620/#693)"
|
||||
"authorized (fail closed, #620)"
|
||||
)
|
||||
|
||||
return reasons
|
||||
@@ -3472,8 +3597,6 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No
|
||||
if lock.get("correction_authorized"):
|
||||
lock["correction_authorized"] = False
|
||||
lock["correction_reason"] = None
|
||||
lock["correction_pr_number"] = None
|
||||
lock["correction_head_sha"] = None
|
||||
_save_review_decision_lock(lock)
|
||||
|
||||
|
||||
@@ -3517,13 +3640,11 @@ def terminal_review_hard_stop_reasons(
|
||||
and last.get("pr_number") == pr_number
|
||||
):
|
||||
return []
|
||||
if operation in ("mark_ready", "review", "resume") and (
|
||||
stale_review_decision_lock.correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
)
|
||||
if operation in ("mark_ready", "review", "resume") and lock.get(
|
||||
"correction_authorized"
|
||||
):
|
||||
return []
|
||||
# #620 same-PR new head + #693 cross-PR isolation.
|
||||
# #620: same PR, different reviewed head → fresh decision boundary.
|
||||
if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=pr_number,
|
||||
@@ -3545,20 +3666,16 @@ def terminal_review_hard_stop_reasons(
|
||||
else ""
|
||||
)
|
||||
recovery = (
|
||||
"exact_next_action: if that PR is already merged/closed, use "
|
||||
"gitea_cleanup_stale_review_decision_lock(apply=true) after live-state "
|
||||
"proof (#594); if the same PR head moved, mark/submit with the new "
|
||||
"expected_head_sha (#620); if the target is a different PR, "
|
||||
"gitea_diagnose_review_decision_lock then mark_final on the target "
|
||||
"(cross-PR isolation, #693); if same-head verdict was mistaken, "
|
||||
"gitea_authorize_review_correction with matching target_pr_number "
|
||||
"(not a generic unlock); never delete session-state files by hand; "
|
||||
"if still blocked create/update a durable Gitea issue and stop"
|
||||
"if that PR is already merged/closed, use "
|
||||
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
|
||||
"proof (#594); if the PR is still open but the head moved, mark/submit "
|
||||
"with the new expected_head_sha (#620); never delete session-state "
|
||||
"files by hand"
|
||||
)
|
||||
return [
|
||||
"terminal review mutation already consumed in this run "
|
||||
f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
|
||||
f"{guidance} (fail closed, #332/#693); {recovery}"
|
||||
f"{guidance} (fail closed, #332); {recovery}"
|
||||
]
|
||||
|
||||
|
||||
@@ -4140,33 +4257,10 @@ def gitea_mark_final_review_decision(
|
||||
"reasons": [
|
||||
"review decision lock missing; resolve review_pr capability first"
|
||||
],
|
||||
"exact_next_action": (
|
||||
"gitea_resolve_task_capability(task='review_pr') then retry "
|
||||
"mark_final"
|
||||
),
|
||||
"durable_issue_handoff": {
|
||||
"required": True,
|
||||
"instruction": (
|
||||
"If the lock cannot be seeded, create/update a durable "
|
||||
"Gitea issue and stop (#693 AC8)"
|
||||
),
|
||||
},
|
||||
}
|
||||
session_reasons = _review_decision_session_reasons(lock)
|
||||
if session_reasons:
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
session_blockers=session_reasons,
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
return {
|
||||
"marked_ready": False,
|
||||
**fail,
|
||||
}
|
||||
return {"marked_ready": False, "reasons": session_reasons}
|
||||
if remote != lock.get("remote"):
|
||||
return {
|
||||
"marked_ready": False,
|
||||
@@ -4174,7 +4268,6 @@ def gitea_mark_final_review_decision(
|
||||
f"requested remote '{remote}' does not match locked remote "
|
||||
f"'{lock.get('remote')}' (fail closed)"
|
||||
],
|
||||
"exact_next_action": "use the remote bound on the decision lock",
|
||||
}
|
||||
try:
|
||||
_, resolved_org, resolved_repo = _resolve(remote, None, org, repo)
|
||||
@@ -4209,26 +4302,7 @@ def gitea_mark_final_review_decision(
|
||||
pr_number, "mark_ready", expected_head_sha=expected_head_sha
|
||||
)
|
||||
if hard_stop:
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
active_profile_identity=_decision_lock_binding().get(
|
||||
"profile_identity"
|
||||
),
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
# Prefer classification next_action when more precise; keep hard_stop
|
||||
# text as primary reasons for operators.
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": hard_stop + list(fail.get("reasons") or []),
|
||||
"classification": fail.get("classification"),
|
||||
"exact_next_action": fail.get("exact_next_action"),
|
||||
"durable_issue_handoff": fail.get("durable_issue_handoff"),
|
||||
}
|
||||
return {"marked_ready": False, "reasons": hard_stop}
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
return {
|
||||
"marked_ready": False,
|
||||
@@ -4244,63 +4318,17 @@ def gitea_mark_final_review_decision(
|
||||
"expected_head_sha required before marking final review "
|
||||
"decision (fail closed, #399)"
|
||||
],
|
||||
"exact_next_action": "retry mark_final with expected_head_sha pin",
|
||||
}
|
||||
# Safe idempotency: already ready for same PR/action/head (#693 AC4).
|
||||
ready_head = stale_review_decision_lock.normalize_head_sha(
|
||||
lock.get("ready_expected_head_sha")
|
||||
)
|
||||
target_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
|
||||
if (
|
||||
lock.get("final_review_decision_ready")
|
||||
and lock.get("ready_pr_number") == pr_number
|
||||
and lock.get("ready_action") == action
|
||||
and ready_head
|
||||
and target_head
|
||||
and stale_review_decision_lock.heads_equal(ready_head, target_head)
|
||||
and lock.get("ready_remote") == remote
|
||||
and lock.get("ready_org") == org
|
||||
and lock.get("ready_repo") == repo
|
||||
):
|
||||
return {
|
||||
"marked_ready": True,
|
||||
"idempotent": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"final_review_decision_ready": True,
|
||||
"head_scoped": True,
|
||||
"reasons": [
|
||||
"final review decision already marked ready for this PR/action/"
|
||||
"head (idempotent, #693)"
|
||||
],
|
||||
}
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": [
|
||||
"cannot mark final decision after a live review mutation was "
|
||||
"already recorded for this PR head unless an operator-approved "
|
||||
"correction was authorized for this same PR/head "
|
||||
"(fail closed, #620/#693)"
|
||||
]
|
||||
+ list(fail.get("reasons") or []),
|
||||
"classification": fail.get("classification"),
|
||||
"exact_next_action": fail.get("exact_next_action"),
|
||||
"durable_issue_handoff": fail.get("durable_issue_handoff"),
|
||||
"correction was authorized (fail closed, #620)"
|
||||
],
|
||||
}
|
||||
elig = gitea_check_pr_eligibility(
|
||||
pr_number=pr_number,
|
||||
@@ -4384,19 +4412,8 @@ def gitea_authorize_review_correction(
|
||||
prior_review_state: str,
|
||||
reason: str,
|
||||
operator_authorized: bool = False,
|
||||
target_pr_number: int | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
post_audit_comment: bool = True,
|
||||
) -> dict:
|
||||
"""Authorize one operator-approved correction after a mistaken live review.
|
||||
|
||||
#693: correction is scoped to the named prior review's **PR and head**.
|
||||
It cannot unlock a different PR (no generic decision-lock bypass).
|
||||
Successful authorization posts a thread-visible audit on the target PR.
|
||||
"""
|
||||
"""Authorize one operator-approved correction after a mistaken live review."""
|
||||
reason = (reason or "").strip()
|
||||
prior_review_state = (prior_review_state or "").strip().lower()
|
||||
lock = _load_review_decision_lock()
|
||||
@@ -4422,20 +4439,7 @@ def gitea_authorize_review_correction(
|
||||
last_mutation = prior[-1]
|
||||
last_review_id = last_mutation.get("review_id")
|
||||
last_review_state = last_mutation.get("action")
|
||||
last_pr = last_mutation.get("pr_number")
|
||||
last_head = stale_review_decision_lock.mutation_head_sha(last_mutation, lock)
|
||||
reasons = []
|
||||
if target_pr_number is None:
|
||||
reasons.append(
|
||||
"target_pr_number is required so correction cannot become a "
|
||||
"generic unlock (fail closed, #693)"
|
||||
)
|
||||
elif last_pr is not None and int(target_pr_number) != int(last_pr):
|
||||
reasons.append(
|
||||
f"target_pr_number #{target_pr_number} does not match last "
|
||||
f"recorded mutation PR #{last_pr}; correction cannot unlock a "
|
||||
f"different PR (fail closed, #693)"
|
||||
)
|
||||
if last_review_id is not None and last_review_id != prior_review_id:
|
||||
reasons.append(
|
||||
f"prior review ID '{prior_review_id}' does not match last "
|
||||
@@ -4446,202 +4450,14 @@ def gitea_authorize_review_correction(
|
||||
f"prior review state '{prior_review_state}' does not match last "
|
||||
f"recorded review state '{last_review_state}' (fail closed)"
|
||||
)
|
||||
want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
|
||||
if want_head and last_head and not stale_review_decision_lock.heads_equal(
|
||||
want_head, last_head
|
||||
):
|
||||
reasons.append(
|
||||
f"expected_head_sha does not match last mutation head "
|
||||
f"{last_head[:12]}… (fail closed, #693)"
|
||||
)
|
||||
if not reason:
|
||||
reasons.append("correction reason is required")
|
||||
try:
|
||||
actor = None
|
||||
h, o, r = _resolve(remote, None, org, repo)
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"authorized": False,
|
||||
"reasons": [f"could not resolve remote for audit: {_redact(str(exc))}"],
|
||||
}
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
audit = stale_review_decision_lock.build_correction_audit_record(
|
||||
prior_review_id=prior_review_id,
|
||||
prior_review_state=prior_review_state,
|
||||
target_pr_number=target_pr_number if target_pr_number is not None else last_pr,
|
||||
target_head_sha=want_head or last_head,
|
||||
reason=reason,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
authorized=False,
|
||||
)
|
||||
if reasons:
|
||||
return {
|
||||
"authorized": False,
|
||||
"reasons": reasons,
|
||||
"audit": audit,
|
||||
"exact_next_action": (
|
||||
"use same-PR correction only, or for a different PR use "
|
||||
"gitea_diagnose_review_decision_lock / cross-PR isolation "
|
||||
"(#693) or moot cleanup (#594)"
|
||||
),
|
||||
}
|
||||
scope_pr = int(target_pr_number) if target_pr_number is not None else int(last_pr)
|
||||
scope_head = want_head or last_head
|
||||
return {"authorized": False, "reasons": reasons}
|
||||
lock["correction_authorized"] = True
|
||||
lock["correction_reason"] = reason
|
||||
lock["correction_pr_number"] = scope_pr
|
||||
lock["correction_head_sha"] = scope_head
|
||||
_save_review_decision_lock(lock)
|
||||
audit = stale_review_decision_lock.build_correction_audit_record(
|
||||
prior_review_id=prior_review_id,
|
||||
prior_review_state=prior_review_state,
|
||||
target_pr_number=scope_pr,
|
||||
target_head_sha=scope_head,
|
||||
reason=reason,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
authorized=True,
|
||||
)
|
||||
report = {
|
||||
"authorized": True,
|
||||
"correction_reason": reason,
|
||||
"correction_pr_number": scope_pr,
|
||||
"correction_head_sha": scope_head,
|
||||
"audit": audit,
|
||||
"audit_comment_id": None,
|
||||
"reasons": [],
|
||||
"scope": "same_pr_head_only",
|
||||
}
|
||||
if post_audit_comment and scope_pr is not None:
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
report["audit_comment_skipped"] = comment_block
|
||||
else:
|
||||
try:
|
||||
auth = _auth(h)
|
||||
body = stale_review_decision_lock.format_correction_audit_comment(
|
||||
audit
|
||||
)
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=scope_pr,
|
||||
request_metadata={"source": "authorize_review_correction"},
|
||||
):
|
||||
posted = api_request(
|
||||
"POST",
|
||||
f"{repo_api_url(h, o, r)}/issues/{scope_pr}/comments",
|
||||
auth,
|
||||
{"body": body},
|
||||
)
|
||||
report["audit_comment_id"] = (posted or {}).get("id")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["audit_comment_error"] = _redact(str(exc))
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_diagnose_review_decision_lock(
|
||||
target_pr_number: int,
|
||||
expected_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: classify durable review-decision lock state for a target PR (#693).
|
||||
|
||||
Returns a deterministic classification, owner/evidence, exact next_action,
|
||||
and whether mark_final / cleanup / scoped correction are appropriate.
|
||||
Never mutates the lock and never authorizes correction.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
binding = _decision_lock_binding()
|
||||
lock = _load_review_decision_lock()
|
||||
session_blockers = _review_decision_session_reasons(lock) if lock else []
|
||||
last = stale_review_decision_lock.last_terminal_mutation(lock)
|
||||
pr_live = None
|
||||
pr_lookup_error = None
|
||||
last_pr = last.get("pr_number") if last else None
|
||||
if last_pr is not None:
|
||||
try:
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{last_pr}", auth
|
||||
) or {}
|
||||
if not pr_live:
|
||||
pr_lookup_error = "empty PR payload"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pr_lookup_error = _redact(str(exc))
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
pr_live=pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=binding.get("profile_identity"),
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
recovery = stale_review_decision_lock.assess_open_pr_decision_lock_recovery(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
last_terminal_pr_live=pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=binding.get("profile_identity"),
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
return {
|
||||
"success": True,
|
||||
"remote": remote,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"authenticated_username": actor,
|
||||
"active_profile_identity": binding.get("profile_identity"),
|
||||
"classification": classification.get("classification"),
|
||||
"mark_final_allowed": classification.get("mark_final_allowed"),
|
||||
"cleanup_allowed": classification.get("cleanup_allowed"),
|
||||
"correction_eligible": classification.get("correction_eligible"),
|
||||
"recovery_allowed": recovery.get("recovery_allowed"),
|
||||
"recovery_mode": recovery.get("recovery_mode"),
|
||||
"exact_next_action": classification.get("next_action"),
|
||||
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||
"last_terminal_action": classification.get("last_terminal_action"),
|
||||
"locked_head_sha": classification.get("locked_head_sha"),
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": stale_review_decision_lock.normalize_head_sha(
|
||||
expected_head_sha
|
||||
),
|
||||
"lock_summary": classification.get("lock_summary"),
|
||||
"evidence": classification.get("evidence"),
|
||||
"reasons": list(classification.get("reasons") or []),
|
||||
"manual_file_delete_forbidden": True,
|
||||
"correction_as_generic_unlock_forbidden": True,
|
||||
"durable_issue_handoff": stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
).get("durable_issue_handoff"),
|
||||
}
|
||||
return {"authorized": True, "correction_reason": reason, "reasons": []}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -8372,6 +8188,16 @@ def gitea_diagnose_reviewer_pr_lease_handoff(
|
||||
except Exception:
|
||||
worktree_clean = None
|
||||
|
||||
# #702: derive owner-process evidence from the durable session-lease
|
||||
# shadow left behind by a crashed daemon. Only a provably dead recorded
|
||||
# owner pid yields False; PID liveness alone never proves ownership.
|
||||
orphan_evidence = reviewer_pr_lease.assess_crashed_session_lease_orphan(
|
||||
reviewer_pr_lease.load_session_lease_shadow(
|
||||
session_id=(active or {}).get("session_id")
|
||||
),
|
||||
lease=active,
|
||||
)
|
||||
|
||||
diagnosis = reviewer_pr_lease.diagnose_reviewer_pr_lease_handoff(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
@@ -8385,8 +8211,9 @@ def gitea_diagnose_reviewer_pr_lease_handoff(
|
||||
formal_reviews=formal_reviews,
|
||||
worktree_exists=worktree_exists,
|
||||
worktree_clean=worktree_clean,
|
||||
owner_process_alive=None, # PID never proves ownership; leave unset
|
||||
owner_process_alive=orphan_evidence.get("owner_process_alive"),
|
||||
)
|
||||
diagnosis["crashed_session_lease_evidence"] = orphan_evidence
|
||||
diagnosis["success"] = True
|
||||
diagnosis["remote"] = remote
|
||||
diagnosis["authenticated_user"] = username
|
||||
@@ -8616,6 +8443,15 @@ def gitea_cleanup_obsolete_reviewer_comment_lease(
|
||||
worktree_clean = None
|
||||
|
||||
session = reviewer_pr_lease.get_session_lease() or {}
|
||||
# #702: when the caller supplies no owner evidence, consult the durable
|
||||
# session-lease shadow; an explicitly passed value always wins.
|
||||
if owner_process_alive is None:
|
||||
owner_process_alive = reviewer_pr_lease.assess_crashed_session_lease_orphan(
|
||||
reviewer_pr_lease.load_session_lease_shadow(
|
||||
session_id=(lease or {}).get("session_id")
|
||||
),
|
||||
lease=lease,
|
||||
).get("owner_process_alive")
|
||||
assessment = reviewer_pr_lease.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
@@ -10465,6 +10301,17 @@ def gitea_get_runtime_context(
|
||||
PROJECT_ROOT),
|
||||
}
|
||||
|
||||
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
|
||||
# binding; recovery itself runs during capability resolution.
|
||||
try:
|
||||
stale_binding = _assess_stale_active_binding(auto_recover=False)
|
||||
if stale_binding.get("classification") != (
|
||||
stale_binding_recovery.CLASSIFICATION_UNBOUND
|
||||
):
|
||||
result["stale_binding_recovery"] = stale_binding
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parity = _current_master_parity()
|
||||
result["master_parity"] = {
|
||||
"in_parity": parity["in_parity"],
|
||||
@@ -11898,6 +11745,18 @@ def gitea_resolve_task_capability(
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
|
||||
# #702 F1: validate (and, when provably stale, recover) the inherited
|
||||
# GITEA_ACTIVE_WORKTREE binding BEFORE the terminal launcher probe. The
|
||||
# probe uses that binding as its cwd; a worktree removed mid-session
|
||||
# would otherwise wedge every mutation-task resolution on 'missing cwd'
|
||||
# before the sanctioned recovery could ever run. Recovery stays
|
||||
# fail-closed: only the provably-stale classes clear, with the durable
|
||||
# audit record persisted first (F6).
|
||||
try:
|
||||
stale_binding = _assess_stale_active_binding(auto_recover=True)
|
||||
except Exception:
|
||||
stale_binding = None
|
||||
|
||||
# ── Terminal Launcher Preflight Check (#556) ──
|
||||
if task in native_mcp_preference.GITEA_MUTATION_TASKS:
|
||||
in_test = _preflight_in_test_mode()
|
||||
@@ -11921,7 +11780,7 @@ def gitea_resolve_task_capability(
|
||||
"Do not attempt git/pytest finalization or unsafe fallbacks. "
|
||||
"Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop."
|
||||
)
|
||||
return {
|
||||
blocked_result = {
|
||||
"requested_task": task,
|
||||
"required_operation_permission": required_permission,
|
||||
"required_role_kind": required_role,
|
||||
@@ -11941,6 +11800,11 @@ def gitea_resolve_task_capability(
|
||||
"different_mcp_namespace_required": False,
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
if stale_binding is not None and stale_binding.get(
|
||||
"classification"
|
||||
) != stale_binding_recovery.CLASSIFICATION_UNBOUND:
|
||||
blocked_result["stale_binding_recovery"] = stale_binding
|
||||
return blocked_result
|
||||
|
||||
record_preflight_check("capability", required_role, resolved_task=task)
|
||||
|
||||
@@ -12130,6 +11994,10 @@ def gitea_resolve_task_capability(
|
||||
"different_mcp_namespace_required": different_namespace_required,
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
if stale_binding is not None and stale_binding.get("classification") != (
|
||||
stale_binding_recovery.CLASSIFICATION_UNBOUND
|
||||
):
|
||||
result["stale_binding_recovery"] = stale_binding
|
||||
if reason_msg:
|
||||
result["reason"] = reason_msg
|
||||
if task in ("review_pr", "merge_pr"):
|
||||
@@ -13005,4 +12873,11 @@ if __name__ == "__main__":
|
||||
# processes (e.g. review_pr.py) can detect and refuse profile
|
||||
# side-channel overrides (#199).
|
||||
_export_session_profile_lock()
|
||||
# #702: an auto-reconnected daemon inherits GITEA_ACTIVE_WORKTREE from its
|
||||
# parent environment. Validate it at boot and clear it only when provably
|
||||
# stale (missing path); anything less certain is surfaced, not cleared.
|
||||
try:
|
||||
_assess_stale_active_binding(auto_recover=True)
|
||||
except Exception:
|
||||
pass
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
+96
-10
@@ -36,6 +36,25 @@ 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 shadow of the in-memory reviewer session lease (#702). Written on
|
||||
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||
# pid + session id) for the guarded lease-cleanup path. Never a substitute
|
||||
# for the in-session lease: mutation gates ignore it.
|
||||
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
|
||||
# Durable audit record written whenever the daemon's sanctioned stale-binding
|
||||
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
|
||||
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
|
||||
|
||||
# Kinds that carry recovery-critical crash-orphan evidence (#702). They are
|
||||
# exempt from TTL expiry: a crashed daemon can never refresh its own record,
|
||||
# so a wall-clock TTL would silently destroy the only owner-exit proof the
|
||||
# guarded cleanup path accepts. These records live until a sanctioned clear
|
||||
# terminally reconciles them (durable lifecycle), never until a timer fires.
|
||||
RECOVERY_CRITICAL_KINDS = frozenset({
|
||||
KIND_REVIEWER_SESSION_LEASE,
|
||||
KIND_STALE_BINDING_RECOVERY,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +105,7 @@ def state_key(
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
@@ -93,17 +113,20 @@ def state_key(
|
||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||
inside the payload and validated on load (#559), which lets a later daemon
|
||||
process recover state without already knowing the remote argument.
|
||||
|
||||
*instance_id* extends the key for kinds where one-per-profile collides —
|
||||
concurrent same-profile sessions each own their reviewer-lease shadow
|
||||
(#702 F5), keyed by their lease session id so a later heartbeat can never
|
||||
overwrite or misattribute another session's crash evidence.
|
||||
"""
|
||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
parts = [kind, profile_identity or "unknown-profile"]
|
||||
instance = (instance_id or "").strip()
|
||||
if instance:
|
||||
parts.append(instance)
|
||||
return "-".join(_sanitize_segment(part) for part in parts)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
@@ -114,6 +137,7 @@ def state_file_path(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
@@ -122,6 +146,7 @@ def state_file_path(
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
@@ -242,10 +267,12 @@ def identity_match_reasons(
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
record_kind = (str(record.get("kind") or "")).strip()
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if record_kind not in RECOVERY_CRITICAL_KINDS:
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if age < timedelta(0):
|
||||
reasons.append("session state recorded_at is in the future (fail closed)")
|
||||
return reasons
|
||||
@@ -259,6 +286,7 @@ def load_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
@@ -269,6 +297,7 @@ def load_state(
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -314,6 +343,7 @@ def save_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
@@ -335,6 +365,7 @@ def save_state(
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -356,6 +387,8 @@ def save_state(
|
||||
body["session_profile_lock"] = profile
|
||||
body["recorded_at"] = body.get("recorded_at") or now
|
||||
body["updated_at"] = now
|
||||
if (instance_id or "").strip():
|
||||
body["instance_id"] = instance_id.strip()
|
||||
if key_remote is not None:
|
||||
body["remote"] = key_remote
|
||||
if key_org is not None:
|
||||
@@ -387,6 +420,7 @@ def clear_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
@@ -396,4 +430,56 @@ def clear_state(
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def list_states(
|
||||
*,
|
||||
kind: str,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Enumerate durable records of *kind* across all instance keys.
|
||||
|
||||
Crash recovery cannot know which session id left a shadow behind, so it
|
||||
must be able to enumerate every record of a kind (#702 F5). Identity
|
||||
checks (profile / TTL policy) apply per record exactly as in
|
||||
:func:`load_state`; records that fail closed are omitted.
|
||||
"""
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
if not root or not os.path.isdir(root):
|
||||
return []
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
return []
|
||||
for name in names:
|
||||
if not name.endswith(".json") or name.startswith("."):
|
||||
continue
|
||||
envelope = _read_json(os.path.join(root, name))
|
||||
if not envelope or envelope.get("kind") != kind:
|
||||
continue
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
if identity_match_reasons(merged, profile_identity=profile):
|
||||
continue
|
||||
results.append(merged)
|
||||
return results
|
||||
|
||||
@@ -56,23 +56,46 @@ def resolve_namespace_workspace(
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||
|
||||
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||
are demoted (#702): 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
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
for candidate, source, env_sourced in (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(_env_value(env_map, role_env_key),
|
||||
f"{role_env_key} environment variable", True),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
"reviewer PR lease worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
if not text:
|
||||
continue
|
||||
real = os.path.realpath(os.path.abspath(text))
|
||||
if verify_paths and env_sourced and not os.path.isdir(real):
|
||||
if demotions is not None:
|
||||
demotions.append(
|
||||
f"{source} '{real}' demoted: path no longer exists "
|
||||
"(stale binding, #702)"
|
||||
)
|
||||
continue
|
||||
return real, source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
@@ -88,6 +111,7 @@ def resolve_namespace_mutation_context(
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
@@ -96,6 +120,8 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
@@ -111,7 +137,7 @@ def resolve_namespace_mutation_context(
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
|
||||
@@ -407,18 +407,194 @@ def record_session_lease(
|
||||
if lease_provenance:
|
||||
stored["lease_provenance"] = dict(lease_provenance)
|
||||
_SESSION_LEASE = stored
|
||||
_persist_session_lease_shadow(stored)
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
def clear_session_lease() -> None:
|
||||
global _SESSION_LEASE
|
||||
prior = _SESSION_LEASE
|
||||
_SESSION_LEASE = None
|
||||
_persist_session_lease_shadow(None, prior=prior)
|
||||
|
||||
|
||||
def get_session_lease() -> dict[str, Any] | None:
|
||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||
|
||||
|
||||
def _persist_session_lease_shadow(
|
||||
lease: dict[str, Any] | None,
|
||||
*,
|
||||
prior: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Best-effort durable shadow of the in-session lease (#702).
|
||||
|
||||
A daemon that dies without teardown leaves this record behind, giving a
|
||||
later process provable orphan evidence (owner pid + session id) for the
|
||||
guarded cleanup path. Observability only — mutation gates never read it,
|
||||
and failures here must never block lease operations.
|
||||
|
||||
Shadows are keyed by lease session id (#702 F5): concurrent same-profile
|
||||
sessions each own a distinct record, so one session's heartbeat can never
|
||||
overwrite or misattribute another's crash evidence. A sanctioned clear is
|
||||
the terminal reconciliation of that record's lifecycle.
|
||||
"""
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
if lease is None:
|
||||
sid = ((prior or {}).get("session_id") or "").strip() or None
|
||||
if sid:
|
||||
mss.clear_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||
)
|
||||
# Legacy single-slot record from pre-F5 code: clearing it is safe
|
||||
# because collision-safe writes never target that key again.
|
||||
mss.clear_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
return
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
instance_id=((lease.get("session_id") or "").strip() or None),
|
||||
payload={
|
||||
"pr_number": lease.get("pr_number"),
|
||||
"session_id": lease.get("session_id"),
|
||||
"comment_id": lease.get("comment_id"),
|
||||
"candidate_head": lease.get("candidate_head"),
|
||||
"worktree": lease.get("worktree"),
|
||||
"phase": lease.get("phase"),
|
||||
"reviewer_identity": lease.get("reviewer_identity"),
|
||||
"profile": lease.get("profile"),
|
||||
"lease_repo": lease.get("repo"),
|
||||
"owner_pid": os.getpid(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_session_lease_shadow(
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load the durable session-lease shadow left by this profile identity.
|
||||
|
||||
With *session_id*, load that session's collision-safe record (#702 F5),
|
||||
falling back to the legacy single-slot record only when its payload names
|
||||
the same session. Without *session_id*, return the legacy record or the
|
||||
sole surviving instance record — ambiguity (multiple candidates) returns
|
||||
``None`` because a shadow that cannot be attributed proves nothing.
|
||||
"""
|
||||
try:
|
||||
import mcp_session_state as mss
|
||||
|
||||
sid = (session_id or "").strip()
|
||||
if sid:
|
||||
record = mss.load_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||
)
|
||||
if record is not None:
|
||||
return record
|
||||
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if legacy and (legacy.get("session_id") or "").strip() == sid:
|
||||
return legacy
|
||||
return None
|
||||
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
if len(records) == 1:
|
||||
return records[0]
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Probe process existence; fail closed toward 'alive' when unsure."""
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def assess_crashed_session_lease_orphan(
|
||||
shadow: dict[str, Any] | None,
|
||||
*,
|
||||
lease: dict[str, Any] | None,
|
||||
current_pid: int | None = None,
|
||||
pid_alive: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Derive owner-process evidence for a durable lease from the shadow (#702).
|
||||
|
||||
Returns ``owner_process_alive`` tri-state. Only a provably dead recorded
|
||||
owner pid yields ``False`` (a dead pid cannot still be running the owning
|
||||
daemon); an alive pid is *not* ownership proof and stays ``None`` unless
|
||||
it is this very process. Session ids must match exactly — the shadow of a
|
||||
different lease proves nothing.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
if not shadow or not lease:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": ["no durable session-lease shadow evidence available"],
|
||||
}
|
||||
shadow_sid = (shadow.get("session_id") or "").strip()
|
||||
lease_sid = (lease.get("session_id") or "").strip()
|
||||
if not shadow_sid or not lease_sid or shadow_sid != lease_sid:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": [
|
||||
"session-lease shadow session_id does not match the durable "
|
||||
f"lease (shadow={shadow_sid or None}, lease={lease_sid or None})"
|
||||
],
|
||||
}
|
||||
owner_pid = shadow.get("owner_pid") or shadow.get("writer_pid")
|
||||
try:
|
||||
owner_pid = int(owner_pid)
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"reasons": ["session-lease shadow has no usable owner pid (fail closed)"],
|
||||
}
|
||||
this_pid = current_pid if current_pid is not None else os.getpid()
|
||||
if owner_pid == this_pid:
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": True,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": ["shadow owner pid is the current process"],
|
||||
}
|
||||
alive = pid_alive if pid_alive is not None else _pid_alive(owner_pid)
|
||||
if alive:
|
||||
reasons.append(
|
||||
f"shadow owner pid {owner_pid} observed alive; PID liveness is not "
|
||||
"ownership proof — owner evidence stays unknown (fail closed)"
|
||||
)
|
||||
return {
|
||||
"orphan_evidence": False,
|
||||
"owner_process_alive": None,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": reasons,
|
||||
}
|
||||
reasons.append(
|
||||
f"shadow owner pid {owner_pid} is dead: the daemon that recorded the "
|
||||
"matching session lease exited without teardown (crash orphan, #702)"
|
||||
)
|
||||
return {
|
||||
"orphan_evidence": True,
|
||||
"owner_process_alive": False,
|
||||
"owner_pid": owner_pid,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def assess_mutation_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
@@ -557,6 +733,7 @@ _HANDOFF_CLASSIFICATIONS = frozenset({
|
||||
"foreign_completed_superseded_head",
|
||||
"foreign_expired_superseded_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
"ambiguous_conflicting_evidence",
|
||||
"instructed_lease_missing_with_replacement",
|
||||
"worktree_binding_mismatch",
|
||||
@@ -850,6 +1027,38 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
classification = "foreign_expired_superseded_head"
|
||||
elif head_superseded and has_terminal and not past_expiry:
|
||||
classification = "foreign_completed_superseded_head"
|
||||
elif head_superseded and not has_terminal and past_expiry:
|
||||
# Crash-orphan shape (#702): the lease outlived its head with no
|
||||
# formal terminal review and has now passed canonical expiry, so
|
||||
# it no longer gates fresh acquisition. Guarded cleanup of the
|
||||
# ledger marker still demands provable owner-process-exit
|
||||
# evidence and a safe worktree — never inferred.
|
||||
classification = "orphaned_expired_superseded_head"
|
||||
if owner_process_alive is True:
|
||||
fail_closed_reasons.append(
|
||||
"lease past expiry on superseded head but owner process "
|
||||
"still alive; cleanup denied"
|
||||
)
|
||||
elif owner_process_alive is None:
|
||||
fail_closed_reasons.append(
|
||||
"expired superseded-head lease with no terminal review: "
|
||||
"cleanup requires provable owner-process-exit evidence "
|
||||
"(owner_process_alive=false); the expired lease no longer "
|
||||
"gates fresh acquisition"
|
||||
)
|
||||
elif worktree_clean is False:
|
||||
fail_closed_reasons.append(
|
||||
"owner process absent but worktree dirty; cleanup denied"
|
||||
)
|
||||
elif not (worktree_clean is True or worktree_exists is False):
|
||||
# #702 F3: unknown worktree evidence must fail closed, exactly
|
||||
# like the sibling orphaned_owner_missing gate and the
|
||||
# diagnosis path — cleanup needs affirmative safe evidence.
|
||||
fail_closed_reasons.append(
|
||||
"owner process absent but worktree evidence is unknown; "
|
||||
"cleanup requires affirmative safe evidence "
|
||||
"(worktree_clean=true or worktree_exists=false)"
|
||||
)
|
||||
elif head_superseded and not has_terminal:
|
||||
classification = "ambiguous_conflicting_evidence"
|
||||
fail_closed_reasons.append(
|
||||
@@ -891,6 +1100,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
"foreign_expired_superseded_head",
|
||||
"foreign_expired_current_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
}:
|
||||
fail_closed_reasons.append(
|
||||
"controller/recovery capability required "
|
||||
@@ -909,6 +1119,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
"foreign_completed_superseded_head",
|
||||
"foreign_expired_superseded_head",
|
||||
"orphaned_owner_missing",
|
||||
"orphaned_expired_superseded_head",
|
||||
"foreign_expired_current_head",
|
||||
}
|
||||
# foreign_expired_current_head needs orphan/clean worktree + authority.
|
||||
@@ -1181,6 +1392,30 @@ def diagnose_reviewer_pr_lease_handoff(
|
||||
"foreign lease pinned to superseded head with completed formal "
|
||||
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
|
||||
)
|
||||
elif not is_own and head_superseded and not terminal and past_expiry:
|
||||
# Crash-orphan shape after canonical expiry (#702): the expired
|
||||
# marker no longer gates acquisition, so a fresh reviewer may
|
||||
# acquire at the current head. Guarded ledger cleanup becomes
|
||||
# available only with provable owner-exit evidence.
|
||||
classification = "orphaned_expired_superseded_head"
|
||||
if owner_process_alive is False and (
|
||||
worktree_clean is True or worktree_exists is False
|
||||
):
|
||||
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||
reasons.append(
|
||||
"expired superseded-head lease with no terminal review and "
|
||||
"provable owner-process exit (crash orphan, #702); guarded "
|
||||
"obsolete-lease cleanup eligible"
|
||||
)
|
||||
else:
|
||||
next_action = NEXT_ACTION_ACQUIRE
|
||||
reasons.append(
|
||||
"lease head superseded and lease past canonical expiry with "
|
||||
"no formal terminal review (crash orphan, #702); expired "
|
||||
"marker no longer gates acquisition — acquire a fresh lease "
|
||||
"at the current head; guarded cleanup needs "
|
||||
"owner_process_alive=false and a clean/absent worktree"
|
||||
)
|
||||
elif not is_own and head_superseded and not terminal:
|
||||
classification = "ambiguous_conflicting_evidence"
|
||||
next_action = NEXT_ACTION_WAIT
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Sanctioned recovery for stale env-bound task workspace bindings (#702).
|
||||
|
||||
When the MCP daemon dies from an unhandled exception it never runs teardown,
|
||||
and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its
|
||||
parent environment. That inherited value can permanently bind the fresh
|
||||
session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident).
|
||||
|
||||
This module classifies the active env binding against live session evidence
|
||||
and produces a fail-closed recovery plan. Only two situations permit the
|
||||
daemon to clear the binding on its own:
|
||||
|
||||
- the bound path no longer exists on disk (``provably_stale_missing_path``)
|
||||
- the binding was inherited at daemon boot and a *sanctioned* in-session
|
||||
reviewer/merger lease later bound a different worktree
|
||||
(``superseded_by_session_lease``)
|
||||
|
||||
Everything else is surfaced (``unverified_inherited``) and left for the
|
||||
managed reconnect path — never cleared silently, never rebound to a guessed
|
||||
worktree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
|
||||
CLASSIFICATION_UNBOUND = "unbound"
|
||||
CLASSIFICATION_CORROBORATED = "corroborated"
|
||||
CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited"
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path"
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease"
|
||||
|
||||
CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_UNBOUND,
|
||||
CLASSIFICATION_CORROBORATED,
|
||||
CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
})
|
||||
|
||||
# Roles whose task workspace is owned by a lease lifecycle, so an inherited
|
||||
# generic binding without a corroborating lease is suspect.
|
||||
LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"})
|
||||
|
||||
RECOVERY_ACTION_NONE = "none"
|
||||
RECOVERY_ACTION_CLEAR_ENV = "clear_env"
|
||||
|
||||
|
||||
def _norm(path: str | None) -> str:
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return os.path.realpath(os.path.abspath(text))
|
||||
|
||||
|
||||
def snapshot_boot_bindings(
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture worktree env bindings present when the daemon booted.
|
||||
|
||||
A value present in this snapshot was inherited from the parent
|
||||
environment, not established by any sanctioned tool in this daemon's
|
||||
lifetime.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
return {
|
||||
"active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def classify_active_worktree_binding(
|
||||
*,
|
||||
active_value: str | None,
|
||||
role_env_value: str | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
boot_inherited: bool,
|
||||
path_exists: bool | None,
|
||||
role_kind: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify the GITEA_ACTIVE_WORKTREE binding against live evidence.
|
||||
|
||||
Fail closed: unknown evidence never yields a clear-eligible class.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
active = _norm(active_value)
|
||||
if not active:
|
||||
return {
|
||||
"classification": CLASSIFICATION_UNBOUND,
|
||||
"clear_eligible": False,
|
||||
"active_worktree": None,
|
||||
"reasons": ["no GITEA_ACTIVE_WORKTREE binding present"],
|
||||
}
|
||||
|
||||
role = (role_kind or "").strip().lower()
|
||||
role_env = _norm(role_env_value)
|
||||
lease_wt = _norm(session_lease_worktree)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"active_worktree": active,
|
||||
"boot_inherited": bool(boot_inherited),
|
||||
"role_kind": role or None,
|
||||
"session_lease_worktree": lease_wt or None,
|
||||
"role_env_worktree": role_env or None,
|
||||
}
|
||||
|
||||
if path_exists is False:
|
||||
reasons.append(
|
||||
f"bound worktree '{active}' no longer exists on disk; the binding "
|
||||
"cannot name a valid task workspace"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
"clear_eligible": True,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt == active:
|
||||
reasons.append("binding matches the sanctioned in-session lease worktree")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt != active and boot_inherited:
|
||||
reasons.append(
|
||||
"boot-inherited binding disagrees with the sanctioned in-session "
|
||||
f"lease worktree '{lease_wt}'; the lease is live authority"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
"clear_eligible": True,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if lease_wt and lease_wt != active and not boot_inherited:
|
||||
# Set after boot by tooling; disagreement is surfaced, never auto-cleared.
|
||||
reasons.append(
|
||||
"post-boot binding disagrees with the in-session lease worktree; "
|
||||
"surfacing only (fail closed, no automatic clear)"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if role_env and role_env == active:
|
||||
reasons.append("binding matches the role-specific worktree env binding")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt:
|
||||
reasons.append(
|
||||
"binding was inherited at daemon boot, no sanctioned in-session "
|
||||
f"lease corroborates it, and the {role} role binds workspaces "
|
||||
"through the lease lifecycle; treat as unverified until a fresh "
|
||||
"lease or managed reconnect re-establishes the workspace"
|
||||
)
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
reasons.append("no contradicting evidence; binding treated as corroborated")
|
||||
result.update({
|
||||
"classification": CLASSIFICATION_CORROBORATED,
|
||||
"clear_eligible": False,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn a classification into an explicit, fail-closed recovery plan."""
|
||||
classification = classification_result.get("classification")
|
||||
clear_eligible = bool(classification_result.get("clear_eligible"))
|
||||
reasons = list(classification_result.get("reasons") or [])
|
||||
|
||||
if classification not in CLASSIFICATIONS:
|
||||
return {
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"clear_allowed": False,
|
||||
"classification": classification,
|
||||
"reasons": reasons + ["unknown classification (fail closed)"],
|
||||
}
|
||||
|
||||
if clear_eligible and classification in {
|
||||
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
}:
|
||||
return {
|
||||
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||
"clear_allowed": True,
|
||||
"classification": classification,
|
||||
"active_worktree": classification_result.get("active_worktree"),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
exact_next_action = None
|
||||
if classification == CLASSIFICATION_UNVERIFIED_INHERITED:
|
||||
exact_next_action = (
|
||||
"managed recovery required: reconnect the MCP namespace through "
|
||||
"the managed client configuration (or start a fresh session) so "
|
||||
"the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or "
|
||||
"corroborate the binding by acquiring a lease for that worktree; "
|
||||
"do not clear or rewrite the environment manually"
|
||||
)
|
||||
return {
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"clear_allowed": False,
|
||||
"classification": classification,
|
||||
"active_worktree": classification_result.get("active_worktree"),
|
||||
"reasons": reasons,
|
||||
**({"exact_next_action": exact_next_action} if exact_next_action else {}),
|
||||
}
|
||||
|
||||
|
||||
def apply_recovery(
|
||||
plan: dict[str, Any],
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a clear-eligible plan by removing the stale env binding.
|
||||
|
||||
This is the sanctioned in-daemon mechanism (#702 AC2); callers must
|
||||
persist the returned record for audit. Refuses anything but an explicit
|
||||
``clear_env`` plan.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV:
|
||||
return {
|
||||
"performed": False,
|
||||
"action": RECOVERY_ACTION_NONE,
|
||||
"reasons": ["recovery plan does not authorize clearing (fail closed)"],
|
||||
}
|
||||
cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||
env_map.pop(ACTIVE_WORKTREE_ENV, None)
|
||||
return {
|
||||
"performed": True,
|
||||
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||
"cleared_env": ACTIVE_WORKTREE_ENV,
|
||||
"cleared_value": cleared_value,
|
||||
"classification": plan.get("classification"),
|
||||
"reasons": list(plan.get("reasons") or []),
|
||||
}
|
||||
+13
-457
@@ -1,4 +1,4 @@
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
||||
|
||||
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||
@@ -12,11 +12,6 @@ on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
||||
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
||||
(#594); new-head re-review is allowed without deleting the durable lock.
|
||||
|
||||
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
|
||||
must not block a fresh formal decision on open PR B on the same durable
|
||||
profile lock (cross-PR isolation). Correction authorization is scoped to the
|
||||
named prior review's PR/head and cannot unlock a different PR.
|
||||
|
||||
This module is pure policy (no Gitea I/O). The MCP tool
|
||||
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||
helpers, and only then clears durable state when cleanup is allowed.
|
||||
@@ -85,45 +80,17 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||
return terminals[-1] if terminals else None
|
||||
|
||||
|
||||
def correction_applies(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> bool:
|
||||
"""True when operator correction is active and scoped to the target (#693).
|
||||
|
||||
Global ``correction_authorized`` alone is not enough: correction must name
|
||||
the same PR (and head when recorded) as the decision being re-opened.
|
||||
Missing scope fields on legacy locks fail closed (do not apply).
|
||||
"""
|
||||
if not lock or not lock.get("correction_authorized"):
|
||||
return False
|
||||
corr_pr = lock.get("correction_pr_number")
|
||||
if corr_pr is None:
|
||||
# Legacy unscoped correction — refuse as generic unlock (#693).
|
||||
return False
|
||||
if pr_number is not None and int(corr_pr) != int(pr_number):
|
||||
return False
|
||||
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if corr_head and target and not heads_equal(corr_head, target):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def prior_live_mutations_block_boundary(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int,
|
||||
expected_head_sha: str | None,
|
||||
) -> bool:
|
||||
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
||||
"""True when prior live mutations block a new decision for PR+head (#620).
|
||||
|
||||
Historical terminals on a **different head of the same PR** do not block.
|
||||
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
||||
Same PR + same head (or same PR without a comparable head SHA) blocks
|
||||
unless a scoped correction applies.
|
||||
Any prior mutation on another PR, the same head, or without a comparable
|
||||
head SHA fails closed (blocks).
|
||||
|
||||
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||
@@ -131,9 +98,7 @@ def prior_live_mutations_block_boundary(
|
||||
"""
|
||||
if not lock:
|
||||
return False
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
if lock.get("correction_authorized"):
|
||||
return False
|
||||
prior = list(lock.get("live_mutations") or [])
|
||||
if not prior:
|
||||
@@ -143,14 +108,10 @@ def prior_live_mutations_block_boundary(
|
||||
if not isinstance(m, dict):
|
||||
return True
|
||||
m_pr = m.get("pr_number")
|
||||
if m_pr is None:
|
||||
return True # fail closed — unscoped mutation
|
||||
if int(m_pr) != int(pr_number):
|
||||
# #693: foreign-PR history on the shared durable lock does not block.
|
||||
continue
|
||||
m_head = mutation_head_sha(m, lock)
|
||||
if (
|
||||
target
|
||||
m_pr == pr_number
|
||||
and target
|
||||
and m_head
|
||||
and not heads_equal(m_head, target)
|
||||
):
|
||||
@@ -167,34 +128,23 @@ def terminal_boundary_allows_fresh_decision(
|
||||
expected_head_sha: str | None,
|
||||
operation: str,
|
||||
) -> bool:
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
||||
|
||||
For ``mark_ready`` / ``review`` / ``resume``:
|
||||
|
||||
* **same PR, different head** — allowed (#620)
|
||||
* **different PR than last terminal** — allowed (#693 cross-PR isolation)
|
||||
* **same PR, same head** — not allowed (unless scoped correction)
|
||||
|
||||
Merge is never reopened by head change or cross-PR isolation (approved
|
||||
head merge path is separate).
|
||||
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
||||
the requested head differs from the last terminal's head. Merge is never
|
||||
reopened by head change (approved head merge path is separate).
|
||||
"""
|
||||
if operation not in ("mark_ready", "review", "resume"):
|
||||
return False
|
||||
if not lock:
|
||||
return False
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
if lock.get("correction_authorized"):
|
||||
return True
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
return True
|
||||
last_pr = last.get("pr_number")
|
||||
if last_pr is None:
|
||||
if last.get("pr_number") != pr_number:
|
||||
return False
|
||||
if int(last_pr) != int(pr_number):
|
||||
# #693: last terminal belongs to another PR — do not hard-stop this PR.
|
||||
return True
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if not locked_head or not target:
|
||||
@@ -488,397 +438,3 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- #693 classification / recovery / correction audit -----------------------
|
||||
|
||||
# Deterministic classification labels for diagnostics and recovery routing.
|
||||
CLASS_NO_LOCK = "no_lock"
|
||||
CLASS_READY_FOR_TARGET = "ready_for_target"
|
||||
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
|
||||
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
|
||||
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
|
||||
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
|
||||
CLASS_CORRECTION_ACTIVE = "correction_active"
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
|
||||
CLASS_AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
def classify_review_decision_lock(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int | None = None,
|
||||
target_head_sha: str | None = None,
|
||||
pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministic review-decision-lock classification (#693).
|
||||
|
||||
Returns classification, exact next_action, owner/evidence fields, and
|
||||
whether mark_final / cleanup / correction are appropriate.
|
||||
"""
|
||||
summary = lock_summary(lock)
|
||||
target_head = normalize_head_sha(target_head_sha)
|
||||
result: dict[str, Any] = {
|
||||
"classification": CLASS_NO_LOCK,
|
||||
"has_lock": lock is not None,
|
||||
"lock_summary": summary,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": target_head,
|
||||
"last_terminal_pr": None,
|
||||
"last_terminal_action": None,
|
||||
"locked_head_sha": None,
|
||||
"owner_profile_identity": (summary or {}).get("profile_identity"),
|
||||
"session_profile": (summary or {}).get("session_profile"),
|
||||
"updated_at": (summary or {}).get("updated_at"),
|
||||
"correction_authorized": bool((lock or {}).get("correction_authorized")),
|
||||
"correction_pr_number": (lock or {}).get("correction_pr_number"),
|
||||
"correction_head_sha": normalize_head_sha(
|
||||
(lock or {}).get("correction_head_sha")
|
||||
),
|
||||
"mark_final_allowed": True,
|
||||
"cleanup_allowed": False,
|
||||
"correction_eligible": False,
|
||||
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
|
||||
"reasons": [],
|
||||
"evidence": {},
|
||||
}
|
||||
|
||||
if lock is None:
|
||||
result["reasons"].append("no review decision lock present")
|
||||
return result
|
||||
|
||||
session_blockers = list(session_blockers or [])
|
||||
if session_blockers:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"reconnect matching prgs-reviewer session or wait for lock TTL; "
|
||||
"do not use gitea_authorize_review_correction as unlock; "
|
||||
"do not delete session-state files"
|
||||
)
|
||||
result["reasons"].extend(session_blockers)
|
||||
return result
|
||||
|
||||
stored = (
|
||||
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||
.strip()
|
||||
)
|
||||
active = (active_profile_identity or "").strip()
|
||||
if active and stored and active != stored:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
f"switch to profile '{stored}' or wait for moot cleanup; "
|
||||
"do not use correction authorization as a generic unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"lock profile '{stored}' does not match active '{active}'"
|
||||
)
|
||||
return result
|
||||
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
ready_pr = lock.get("ready_pr_number")
|
||||
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||
if (
|
||||
target_pr_number is not None
|
||||
and ready_pr == target_pr_number
|
||||
and ready_head
|
||||
and target_head
|
||||
and heads_equal(ready_head, target_head)
|
||||
and lock.get("final_review_decision_ready")
|
||||
):
|
||||
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
|
||||
result["mark_final_allowed"] = True # idempotent re-mark
|
||||
result["next_action"] = (
|
||||
"gitea_submit_pr_review with final_review_decision_ready=true "
|
||||
"for the ready PR/head"
|
||||
)
|
||||
result["reasons"].append(
|
||||
"final decision already marked ready for this PR/head (idempotent)"
|
||||
)
|
||||
return result
|
||||
result["classification"] = CLASS_READY_FOR_TARGET
|
||||
result["next_action"] = "gitea_mark_final_review_decision then submit"
|
||||
result["reasons"].append("no terminal live mutations; mark_final allowed")
|
||||
return result
|
||||
|
||||
last_pr = last.get("pr_number")
|
||||
last_action = last.get("action")
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
result["last_terminal_pr"] = last_pr
|
||||
result["last_terminal_action"] = last_action
|
||||
result["locked_head_sha"] = locked_head
|
||||
result["evidence"] = {
|
||||
"last_review_id": last.get("review_id"),
|
||||
"live_mutations_count": len(lock.get("live_mutations") or []),
|
||||
}
|
||||
|
||||
if correction_applies(
|
||||
lock, pr_number=target_pr_number, expected_head_sha=target_head
|
||||
):
|
||||
result["classification"] = CLASS_CORRECTION_ACTIVE
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
|
||||
"then submit once"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
|
||||
)
|
||||
return result
|
||||
|
||||
# Live state of last terminal PR when provided.
|
||||
live = None
|
||||
if pr_lookup_error:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"retry diagnosis after live PR state is available; "
|
||||
"fail closed under ambiguous evidence"
|
||||
)
|
||||
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
|
||||
return result
|
||||
if pr_live is not None:
|
||||
live = classify_pr_live_state(pr_live)
|
||||
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
|
||||
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
|
||||
if live.get("pr_merged_or_closed"):
|
||||
result["classification"] = CLASS_MOOT_MERGED_CLOSED
|
||||
result["cleanup_allowed"] = True
|
||||
result["mark_final_allowed"] = target_pr_number is not None and (
|
||||
int(last_pr) != int(target_pr_number)
|
||||
if last_pr is not None and target_pr_number is not None
|
||||
else True
|
||||
)
|
||||
result["next_action"] = (
|
||||
"gitea_cleanup_stale_review_decision_lock(apply=true, "
|
||||
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
|
||||
)
|
||||
return result
|
||||
|
||||
if target_pr_number is None:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = "re-run diagnosis with target_pr_number"
|
||||
result["reasons"].append("target_pr_number required for open-PR classification")
|
||||
return result
|
||||
|
||||
if last_pr is not None and int(last_pr) != int(target_pr_number):
|
||||
# Cross-PR isolation: foreign terminal is history, not a block.
|
||||
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
|
||||
result["mark_final_allowed"] = True
|
||||
result["correction_eligible"] = False # must not use correction to "unlock"
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
|
||||
f"— foreign terminal on PR #{last_pr} does not block (#693); "
|
||||
"do not call gitea_authorize_review_correction for cross-PR unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
|
||||
f"target PR #{target_pr_number} is isolated (#693)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR as target.
|
||||
if (
|
||||
locked_head
|
||||
and target_head
|
||||
and not heads_equal(locked_head, target_head)
|
||||
):
|
||||
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision with expected_head_sha="
|
||||
f"{target_head} (#620 same-PR new head)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} on head {locked_head[:12]}…; target head "
|
||||
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR + same head (or missing head comparison) — active terminal.
|
||||
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
|
||||
result["mark_final_allowed"] = False
|
||||
result["correction_eligible"] = True
|
||||
result["next_action"] = (
|
||||
"if the prior verdict was mistaken: gitea_authorize_review_correction "
|
||||
f"with prior_review_id={last.get('review_id')}, "
|
||||
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
|
||||
"operator_authorized=true, then re-mark once; "
|
||||
"if the prior verdict is correct: stop and hand off (do not duplicate)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} already recorded for PR #{target_pr_number} "
|
||||
f"at this head; same-head duplicate blocked (#332/#620)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_precise_mark_final_failure(
|
||||
classification: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
|
||||
cls = classification.get("classification")
|
||||
next_action = classification.get("next_action") or (
|
||||
"stop and create a durable Gitea issue with lock evidence"
|
||||
)
|
||||
reasons = list(classification.get("reasons") or [])
|
||||
reasons.append(f"exact_next_action: {next_action}")
|
||||
handoff = {
|
||||
"required": cls
|
||||
in (
|
||||
CLASS_AMBIGUOUS,
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH,
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
|
||||
)
|
||||
and not classification.get("mark_final_allowed"),
|
||||
"title_hint": (
|
||||
"Review-decision-lock recovery failed during formal review"
|
||||
),
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||
"last_terminal_action": classification.get("last_terminal_action"),
|
||||
"locked_head_sha": classification.get("locked_head_sha"),
|
||||
"target_pr_number": classification.get("target_pr_number"),
|
||||
"target_head_sha": classification.get("target_head_sha"),
|
||||
"evidence": classification.get("evidence") or {},
|
||||
"instruction": (
|
||||
"If recovery cannot complete with the exact_next_action above, "
|
||||
"create or update a durable Gitea issue with this payload and stop; "
|
||||
"do not delete session-state files; do not misuse correction as unlock."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"reasons": reasons,
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"durable_issue_handoff": handoff,
|
||||
}
|
||||
|
||||
|
||||
def build_correction_audit_record(
|
||||
*,
|
||||
prior_review_id: int | None,
|
||||
prior_review_state: str | None,
|
||||
target_pr_number: int | None,
|
||||
target_head_sha: str | None,
|
||||
reason: str | None,
|
||||
actor_username: str | None,
|
||||
profile_name: str | None,
|
||||
authorized: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured durable audit for review-correction authorization (#693)."""
|
||||
return {
|
||||
"event": "review_correction_authorization",
|
||||
"issue_ref": "#693",
|
||||
"authorized": bool(authorized),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"actor_username": actor_username,
|
||||
"profile_name": profile_name,
|
||||
"prior_review_id": prior_review_id,
|
||||
"prior_review_state": prior_review_state,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": normalize_head_sha(target_head_sha),
|
||||
"reason": reason,
|
||||
"scope": "same_pr_head_only",
|
||||
}
|
||||
|
||||
|
||||
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
|
||||
"""Markdown body for a thread-visible correction authorization audit."""
|
||||
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
|
||||
lines = [
|
||||
"## Review correction authorization audit (#693)",
|
||||
"",
|
||||
f"Status: **{status}**",
|
||||
"",
|
||||
f"- actor: `{audit.get('actor_username')}`",
|
||||
f"- profile: `{audit.get('profile_name')}`",
|
||||
f"- timestamp: `{audit.get('timestamp')}`",
|
||||
f"- prior_review_id: `{audit.get('prior_review_id')}`",
|
||||
f"- prior_review_state: `{audit.get('prior_review_state')}`",
|
||||
f"- target_pr: `#{audit.get('target_pr_number')}`",
|
||||
f"- target_head_sha: `{audit.get('target_head_sha')}`",
|
||||
f"- reason: {audit.get('reason')}",
|
||||
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
|
||||
"",
|
||||
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
|
||||
"uses isolation (#693) or moot cleanup (#594), never correction.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_open_pr_decision_lock_recovery(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int,
|
||||
target_head_sha: str | None,
|
||||
last_terminal_pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
controller_recovery_authorized: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
|
||||
|
||||
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
|
||||
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
|
||||
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
|
||||
"""
|
||||
classification = classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=target_head_sha,
|
||||
pr_live=last_terminal_pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=active_profile_identity,
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
cls = classification["classification"]
|
||||
recovery_allowed = False
|
||||
recovery_mode = None
|
||||
if cls == CLASS_FOREIGN_PR_TERMINAL:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "cross_pr_isolation"
|
||||
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "same_pr_new_head"
|
||||
elif cls == CLASS_MOOT_MERGED_CLOSED:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "moot_cleanup"
|
||||
elif cls == CLASS_READY_FOR_TARGET:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "none_required"
|
||||
elif cls == CLASS_CORRECTION_ACTIVE:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "scoped_correction"
|
||||
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "correction_only_if_mistake"
|
||||
else:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "fail_closed"
|
||||
|
||||
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
|
||||
# Cleanup still needs apply + capability; assess reports path.
|
||||
pass
|
||||
|
||||
return {
|
||||
**classification,
|
||||
"recovery_allowed": recovery_allowed,
|
||||
"recovery_mode": recovery_mode,
|
||||
"controller_recovery_authorized": bool(controller_recovery_authorized),
|
||||
"manual_file_delete_forbidden": True,
|
||||
"correction_as_generic_unlock_forbidden": True,
|
||||
}
|
||||
|
||||
@@ -117,23 +117,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #693: read-only classification of durable decision locks (incl. open PRs).
|
||||
"diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"delete_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
"role": "author",
|
||||
|
||||
@@ -24,10 +24,6 @@ HEAD_C = "c" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||
mutations = list(mutations or [])
|
||||
corr_pr = None
|
||||
if correction and mutations:
|
||||
corr_pr = mutations[-1].get("pr_number")
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -44,11 +40,9 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
|
||||
"ready_remote": "prgs" if ready_pr else None,
|
||||
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||
"live_mutations": mutations,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": corr_pr,
|
||||
"correction_head_sha": None,
|
||||
"correction_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -180,13 +174,12 @@ class TestHardStopHeadScope(unittest.TestCase):
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
|
||||
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
|
||||
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
|
||||
def test_rc_head_a_blocks_other_pr(self):
|
||||
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||
700, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertEqual(reasons, [])
|
||||
self.assertTrue(reasons)
|
||||
|
||||
def test_legacy_619_ledger_allows_new_head(self):
|
||||
"""PR #619-style durable lock without mutation head_sha."""
|
||||
|
||||
@@ -561,12 +561,29 @@ class TestIssue691SupersededHeadCleanup(unittest.TestCase):
|
||||
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_superseded_without_terminal_review_denied(self):
|
||||
# Pre-expiry the missing terminal review is ambiguous (fail closed);
|
||||
# post-expiry with unknown owner evidence it is a crash-orphan (#702)
|
||||
# that still fails closed until owner-process exit is proven.
|
||||
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||
result = _assess([claim], formal_reviews=[], now=now)
|
||||
result = _assess(
|
||||
[claim], formal_reviews=[], now=now, owner_process_alive=None
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
|
||||
fresh_expires = now + timedelta(minutes=60)
|
||||
fresh = _lease_comment(
|
||||
expires_at=fresh_expires, last_activity=now - timedelta(minutes=5)
|
||||
)
|
||||
pre_expiry = _assess([fresh], formal_reviews=[], now=now)
|
||||
self.assertFalse(pre_expiry["cleanup_allowed"])
|
||||
self.assertEqual(
|
||||
pre_expiry["classification"], "ambiguous_conflicting_evidence"
|
||||
)
|
||||
|
||||
|
||||
class TestIssue691DiagnoseClassifications(unittest.TestCase):
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
"""Review-decision-lock recovery / cross-PR isolation (#693).
|
||||
|
||||
Reproduces the PR #688 → PR #692 formal-review sequence:
|
||||
|
||||
* durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425)
|
||||
* reviewer leases PR #692 @ head B (6d86db…)
|
||||
* mark_final for #692 must succeed without correction unlock
|
||||
* cleanup of open #688 terminal remains forbidden (#594)
|
||||
* authorize_review_correction for #688 cannot unlock #692
|
||||
* eventual #692 APPROVE is unique and head-bound (review_id 426)
|
||||
* mark_final is idempotent for same PR/action/head
|
||||
* diagnosis returns one exact next_action + durable handoff payload
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import mcp_server
|
||||
import review_workflow_load
|
||||
import stale_review_decision_lock as srdl
|
||||
|
||||
# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693)
|
||||
HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
|
||||
HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca"
|
||||
PR_A = 688
|
||||
PR_B = 692
|
||||
REVIEW_ID_A = 425
|
||||
REVIEW_ID_B = 426
|
||||
|
||||
RC_688 = {
|
||||
"pr_number": PR_A,
|
||||
"action": "request_changes",
|
||||
"review_id": REVIEW_ID_A,
|
||||
"review_state": "request_changes",
|
||||
"head_sha": HEAD_688,
|
||||
}
|
||||
APPROVE_692 = {
|
||||
"pr_number": PR_B,
|
||||
"action": "approve",
|
||||
"review_id": REVIEW_ID_B,
|
||||
"review_state": "approve",
|
||||
"head_sha": HEAD_692,
|
||||
}
|
||||
|
||||
|
||||
def _lock(mutations=None, **kwargs):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
base = {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": None,
|
||||
"ready_action": None,
|
||||
"ready_expected_head_sha": None,
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
"correction_pr_number": None,
|
||||
"correction_head_sha": None,
|
||||
"updated_at": now,
|
||||
"recorded_at": now,
|
||||
"writer_pid": os.getpid(),
|
||||
}
|
||||
base.update(kwargs)
|
||||
# Ensure TTL fields stay fresh unless the caller is testing expiry.
|
||||
if "recorded_at" not in kwargs:
|
||||
base["recorded_at"] = now
|
||||
if "updated_at" not in kwargs:
|
||||
base["updated_at"] = now
|
||||
return base
|
||||
|
||||
|
||||
REVIEWER_ENV = {
|
||||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.read,gitea.pr.review,gitea.pr.approve,"
|
||||
"gitea.pr.request_changes,gitea.pr.comment"
|
||||
),
|
||||
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||
}
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.merge"],
|
||||
}
|
||||
|
||||
|
||||
def _seed(mutations=None, **kwargs):
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
|
||||
def _no_lease():
|
||||
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
def _open_pr(number, head):
|
||||
return {
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"merged_at": None,
|
||||
"head": {"sha": head},
|
||||
}
|
||||
|
||||
|
||||
class TestCrossPrIsolationPure(unittest.TestCase):
|
||||
def test_foreign_terminal_does_not_block_mark_boundary(self):
|
||||
lock = _lock([RC_688])
|
||||
self.assertFalse(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_A, expected_head_sha=HEAD_688
|
||||
)
|
||||
)
|
||||
|
||||
def test_terminal_boundary_allows_fresh_decision_on_other_pr(self):
|
||||
lock = _lock([RC_688])
|
||||
self.assertTrue(
|
||||
srdl.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=PR_B,
|
||||
expected_head_sha=HEAD_692,
|
||||
operation="mark_ready",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
srdl.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=PR_A,
|
||||
expected_head_sha=HEAD_688,
|
||||
operation="mark_ready",
|
||||
)
|
||||
)
|
||||
|
||||
def test_unscoped_correction_does_not_apply(self):
|
||||
lock = _lock([RC_688], correction_authorized=True, correction_reason="x")
|
||||
# Missing correction_pr_number → fail closed (#693)
|
||||
self.assertFalse(
|
||||
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||
)
|
||||
|
||||
def test_scoped_correction_only_for_named_pr(self):
|
||||
lock = _lock(
|
||||
[RC_688],
|
||||
correction_authorized=True,
|
||||
correction_reason="mistake",
|
||||
correction_pr_number=PR_A,
|
||||
correction_head_sha=HEAD_688,
|
||||
)
|
||||
self.assertTrue(
|
||||
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||
)
|
||||
self.assertFalse(
|
||||
srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692)
|
||||
)
|
||||
|
||||
|
||||
class TestClassification(unittest.TestCase):
|
||||
def test_foreign_pr_terminal_classification(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=PR_B,
|
||||
target_head_sha=HEAD_692,
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
active_profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||
self.assertTrue(c["mark_final_allowed"])
|
||||
self.assertFalse(c["correction_eligible"])
|
||||
self.assertIn("do not call gitea_authorize_review_correction", c["next_action"])
|
||||
|
||||
def test_same_head_terminal_classification(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=PR_A,
|
||||
target_head_sha=HEAD_688,
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
)
|
||||
self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD)
|
||||
self.assertFalse(c["mark_final_allowed"])
|
||||
self.assertTrue(c["correction_eligible"])
|
||||
|
||||
def test_precise_failure_includes_durable_handoff(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock, target_pr_number=PR_A, target_head_sha=HEAD_688
|
||||
)
|
||||
fail = srdl.build_precise_mark_final_failure(c)
|
||||
self.assertIn("exact_next_action", fail)
|
||||
self.assertIn("durable_issue_handoff", fail)
|
||||
self.assertTrue(fail["durable_issue_handoff"]["required"])
|
||||
|
||||
|
||||
class TestPr692Sequence(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
|
||||
self._env.start()
|
||||
self._prof = patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
)
|
||||
self._prof.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._prof.stop()
|
||||
self._env.stop()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_final_692_succeeds_with_open_688_terminal(self):
|
||||
_seed([RC_688], writer_pid=25883, session_pid=60615)
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
self.assertEqual(lock["ready_pr_number"], PR_B)
|
||||
self.assertEqual(
|
||||
srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692
|
||||
)
|
||||
# Foreign terminal history preserved (non-destructive isolation).
|
||||
self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A)
|
||||
|
||||
def test_mark_final_idempotent_same_binding(self):
|
||||
_seed(
|
||||
[RC_688],
|
||||
final_review_decision_ready=True,
|
||||
ready_pr_number=PR_B,
|
||||
ready_action="approve",
|
||||
ready_expected_head_sha=HEAD_692,
|
||||
ready_remote="prgs",
|
||||
ready_org="Scaled-Tech-Consulting",
|
||||
ready_repo="Gitea-Tools",
|
||||
)
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(result["marked_ready"])
|
||||
self.assertTrue(result.get("idempotent"))
|
||||
|
||||
def test_cleanup_still_forbidden_while_688_open(self):
|
||||
_seed([RC_688])
|
||||
assessment = srdl.assess_stale_review_decision_lock(
|
||||
mcp_server._load_review_decision_lock(),
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
active_profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertFalse(assessment["cleanup_allowed"])
|
||||
self.assertFalse(assessment["is_moot"])
|
||||
|
||||
def test_correction_cannot_unlock_different_pr(self):
|
||||
_seed([RC_688])
|
||||
r = mcp_server.gitea_authorize_review_correction(
|
||||
prior_review_id=REVIEW_ID_A,
|
||||
prior_review_state="request_changes",
|
||||
reason="try unlock 692",
|
||||
operator_authorized=True,
|
||||
target_pr_number=PR_B,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
post_audit_comment=False,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||
|
||||
def test_scoped_correction_for_same_pr_posts_audit(self):
|
||||
_seed([RC_688])
|
||||
with patch(
|
||||
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch("mcp_server.api_request", return_value={"id": 99901}):
|
||||
r = mcp_server.gitea_authorize_review_correction(
|
||||
prior_review_id=REVIEW_ID_A,
|
||||
prior_review_state="request_changes",
|
||||
reason="mistaken RC wording",
|
||||
operator_authorized=True,
|
||||
target_pr_number=PR_A,
|
||||
expected_head_sha=HEAD_688,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
post_audit_comment=True,
|
||||
)
|
||||
self.assertTrue(r["authorized"], r.get("reasons"))
|
||||
self.assertEqual(r.get("correction_pr_number"), PR_A)
|
||||
self.assertEqual(
|
||||
r.get("audit_comment_id"),
|
||||
99901,
|
||||
r.get("audit_comment_skipped") or r.get("audit_comment_error") or r,
|
||||
)
|
||||
|
||||
def test_diagnose_foreign_terminal_next_action(self):
|
||||
_seed([RC_688])
|
||||
with patch(
|
||||
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)):
|
||||
d = mcp_server.gitea_diagnose_review_decision_lock(
|
||||
target_pr_number=PR_B,
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(d["success"], d.get("reasons"))
|
||||
self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||
self.assertTrue(d["mark_final_allowed"])
|
||||
self.assertIn("mark_final", d["exact_next_action"])
|
||||
self.assertTrue(d["correction_as_generic_unlock_forbidden"])
|
||||
|
||||
def test_approval_ledger_unique_and_bound_after_sequence(self):
|
||||
"""After mark_final + record mutation, ledger binds unique approve to 692 head."""
|
||||
_seed([RC_688])
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
marked = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(marked["marked_ready"])
|
||||
mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B)
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
terminals = [
|
||||
m
|
||||
for m in lock["live_mutations"]
|
||||
if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS
|
||||
and m.get("pr_number") == PR_B
|
||||
]
|
||||
self.assertEqual(len(terminals), 1)
|
||||
self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B)
|
||||
self.assertEqual(
|
||||
srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692
|
||||
)
|
||||
# Same-head duplicate still blocked.
|
||||
self.assertTrue(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSessionRestartWriterPid(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_writer_pid_change_does_not_inherit_foreign_block(self):
|
||||
# session_pid 60615 origin, writer later 25883 — still foreign isolation.
|
||||
_seed([RC_688], session_pid=60615, writer_pid=25883)
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(
|
||||
PR_B, "mark_ready", expected_head_sha=HEAD_692
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,693 @@
|
||||
"""Regression tests for the PR #703 REQUEST_CHANGES findings F1–F6 (#702).
|
||||
|
||||
Formal review at head 889931d independently reproduced six defects:
|
||||
|
||||
F1 — stale-binding recovery ran only AFTER the terminal launcher probe in
|
||||
``gitea_resolve_task_capability``; a worktree removed mid-session wedged
|
||||
every mutation-task resolution on 'missing cwd' before recovery.
|
||||
F2 — ``_resolve_preflight_workspace_path`` skipped the existence checks the
|
||||
canonical mutation context applies; a dead binding could produce empty
|
||||
porcelain and read as a clean workspace.
|
||||
F3 — ``orphaned_expired_superseded_head`` cleanup accepted unknown
|
||||
``worktree_exists``/``worktree_clean`` evidence.
|
||||
F4 — the 4-hour session-state TTL silently discarded recovery-critical
|
||||
crash-orphan shadow evidence.
|
||||
F5 — the single same-profile shadow slot let concurrent sessions overwrite
|
||||
each other's crash evidence.
|
||||
F6 — the environment was cleared BEFORE the audit record was persisted, and
|
||||
audit-write failure was swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_session_state as mss # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import reviewer_pr_lease as leases # noqa: E402
|
||||
import stale_binding_recovery as sbr # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
|
||||
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
OLD_HEAD = "b" * 40
|
||||
NEW_HEAD = "6" * 40
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||
"gitea.pr.create", "gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class _CapabilityHarness(unittest.TestCase):
|
||||
"""Shared config/env plumbing for resolve-capability integration tests."""
|
||||
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {
|
||||
"host": "gitea.example.com",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
},
|
||||
})
|
||||
self._remotes.start()
|
||||
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))
|
||||
|
||||
def tearDown(self):
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def _deleted_worktree(self) -> str:
|
||||
path = tempfile.mkdtemp(prefix="gitea-removed-worktree-")
|
||||
shutil.rmtree(path)
|
||||
return path
|
||||
|
||||
|
||||
class TestF1RecoveryBeforeTerminalProbe(_CapabilityHarness):
|
||||
"""F1: recovery must run before the terminal cwd probe can wedge."""
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_removed_worktree_recovers_before_probe(self, _auth, _api):
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
# The provably-stale binding was cleared before the probe ran.
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
self.assertFalse(res.get("terminal_launcher_unhealthy"))
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertEqual(
|
||||
report.get("classification"),
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
self.assertTrue(report.get("recovery_performed"))
|
||||
self.assertTrue(res.get("allowed_in_current_session"))
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_probe_block_still_carries_recovery_report(self, _auth, _api):
|
||||
# Recovery disabled (test mode without the force flag) preserves the
|
||||
# fail-closed probe block, but the block must now carry the
|
||||
# classification evidence instead of hiding it.
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
self.assertIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
self.assertTrue(res.get("terminal_launcher_unhealthy"))
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertEqual(
|
||||
report.get("classification"),
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
self.assertFalse(report.get("recovery_performed"))
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_unverified_binding_is_never_cleared_by_reordering(self, _auth, _api):
|
||||
# F1 must not weaken authorization: an existing, uncorroborated
|
||||
# binding survives resolution untouched even with recovery forced.
|
||||
existing = tempfile.mkdtemp(prefix="gitea-existing-worktree-")
|
||||
self.addCleanup(shutil.rmtree, existing, ignore_errors=True)
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=existing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), existing)
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertFalse(report.get("recovery_performed"))
|
||||
|
||||
|
||||
class TestF2PreflightPathVerification(_CapabilityHarness):
|
||||
"""F2: preflight and mutation contexts verify resolved paths alike."""
|
||||
|
||||
def test_preflight_and_mutation_context_agree_on_dead_binding(self):
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(GITEA_ACTIVE_WORKTREE=missing)
|
||||
with patch.dict(os.environ, env):
|
||||
preflight = mcp_server._resolve_preflight_workspace_path(None)
|
||||
mutation = mcp_server._resolve_namespace_mutation_context(None)
|
||||
self.assertEqual(preflight, mutation["workspace_path"])
|
||||
self.assertNotEqual(preflight, os.path.realpath(missing))
|
||||
|
||||
def test_missing_explicit_worktree_never_reads_clean(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._env()):
|
||||
porcelain = mcp_server._get_workspace_porcelain(worktree_path=missing)
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
# The sentinel parses as a tracked dirty entry — never clean.
|
||||
self.assertTrue(mcp_server._parse_porcelain_entries(porcelain))
|
||||
|
||||
def test_workspace_deleted_during_evaluation_never_reads_clean(self):
|
||||
# TOCTOU: resolution succeeded, then the path vanished before git ran.
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._env()):
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_resolve_preflight_workspace_path",
|
||||
return_value=missing,
|
||||
):
|
||||
porcelain = mcp_server._get_workspace_porcelain()
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
|
||||
def test_git_failure_never_reads_clean(self):
|
||||
class _Failed:
|
||||
returncode = 128
|
||||
stdout = ""
|
||||
stderr = "fatal: not a git repository"
|
||||
|
||||
with patch.dict(os.environ, self._env()):
|
||||
with patch.object(
|
||||
mcp_server.subprocess, "run", return_value=_Failed()
|
||||
):
|
||||
porcelain = mcp_server._get_workspace_porcelain()
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
|
||||
def test_symlinked_binding_to_deleted_target_is_demoted(self):
|
||||
target = tempfile.mkdtemp(prefix="gitea-symlink-target-")
|
||||
holder = tempfile.mkdtemp(prefix="gitea-symlink-holder-")
|
||||
self.addCleanup(shutil.rmtree, holder, ignore_errors=True)
|
||||
link = os.path.join(holder, "worktree-link")
|
||||
os.symlink(target, link)
|
||||
shutil.rmtree(target)
|
||||
demotions: list[str] = []
|
||||
_path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": link},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(len(demotions), 1)
|
||||
|
||||
def test_dead_binding_falls_through_to_live_role_binding(self):
|
||||
# Path changes during evaluation: the dead candidate demotes at
|
||||
# verification time and never resurrects over the live fallback.
|
||||
alive = tempfile.mkdtemp(prefix="gitea-alive-worktree-")
|
||||
self.addCleanup(shutil.rmtree, alive, ignore_errors=True)
|
||||
missing = self._deleted_worktree()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={
|
||||
"GITEA_ACTIVE_WORKTREE": missing,
|
||||
"GITEA_REVIEWER_WORKTREE": alive,
|
||||
},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(alive))
|
||||
self.assertEqual(
|
||||
source, "GITEA_REVIEWER_WORKTREE environment variable"
|
||||
)
|
||||
|
||||
|
||||
class TestF3AffirmativeWorktreeEvidence(unittest.TestCase):
|
||||
"""F3: unknown worktree evidence must fail closed for crash orphans."""
|
||||
|
||||
def _comments(self) -> list[dict]:
|
||||
stale = _now() - timedelta(hours=3)
|
||||
body = leases.format_lease_body(
|
||||
repo=REPO,
|
||||
pr_number=701,
|
||||
issue_number=699,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id="65664-4f248d213d60",
|
||||
worktree="branches/review-pr-654",
|
||||
phase="validation-start",
|
||||
candidate_head=OLD_HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
last_activity=stale,
|
||||
expires_at=stale + timedelta(minutes=120),
|
||||
)
|
||||
return [{
|
||||
"id": 11131,
|
||||
"body": body,
|
||||
"user": {"login": "sysadmin"},
|
||||
"author": "sysadmin",
|
||||
"created_at": stale.isoformat(),
|
||||
"updated_at": stale.isoformat(),
|
||||
}]
|
||||
|
||||
def _assess(self, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=701,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
self._comments(), **defaults
|
||||
)
|
||||
|
||||
def test_unknown_worktree_evidence_fails_closed(self):
|
||||
result = self._assess(worktree_exists=None, worktree_clean=None)
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertIn(
|
||||
"affirmative safe evidence",
|
||||
" ".join(result["fail_closed_reasons"]),
|
||||
)
|
||||
|
||||
def test_unknown_cleanliness_with_existing_worktree_fails_closed(self):
|
||||
result = self._assess(worktree_exists=True, worktree_clean=None)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_affirmative_clean_worktree_is_eligible(self):
|
||||
result = self._assess(worktree_exists=True, worktree_clean=True)
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
|
||||
def test_affirmative_absent_worktree_is_eligible(self):
|
||||
result = self._assess(worktree_exists=False, worktree_clean=None)
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
|
||||
def test_matrix_matches_diagnosis_gate(self):
|
||||
# The cleanup matrix and the diagnosis path must agree: unknown
|
||||
# evidence yields acquire-only, affirmative evidence yields cleanup.
|
||||
diagnosis_unknown = leases.diagnose_reviewer_pr_lease_handoff(
|
||||
self._comments(),
|
||||
pr_number=701,
|
||||
current_session_id=None,
|
||||
current_reviewer_identity="fresh-reviewer",
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
owner_process_alive=False,
|
||||
worktree_exists=None,
|
||||
worktree_clean=None,
|
||||
)
|
||||
assess_unknown = self._assess(worktree_exists=None, worktree_clean=None)
|
||||
self.assertNotEqual(
|
||||
diagnosis_unknown["next_action"],
|
||||
"cleanup_obsolete_reviewer_comment_lease",
|
||||
)
|
||||
self.assertFalse(assess_unknown["cleanup_allowed"])
|
||||
|
||||
|
||||
class TestF4CrashShadowDurability(unittest.TestCase):
|
||||
"""F4: crash-orphan evidence survives the former 4h TTL boundary."""
|
||||
|
||||
SID = "82984-abcabcabcabc"
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||
)
|
||||
self._env.start()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _age_record(self, kind: str, hours: float, instance_id: str | None = None):
|
||||
path = mss.state_file_path(kind=kind, instance_id=instance_id)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
envelope = json.load(fh)
|
||||
stamp = (
|
||||
(_now() - timedelta(hours=hours)).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
envelope["recorded_at"] = stamp
|
||||
envelope["updated_at"] = stamp
|
||||
envelope["payload"]["recorded_at"] = stamp
|
||||
envelope["payload"]["updated_at"] = stamp
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh)
|
||||
|
||||
def _save_shadow(self) -> None:
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
instance_id=self.SID,
|
||||
payload={"session_id": self.SID, "owner_pid": os.getpid()},
|
||||
)
|
||||
|
||||
def _load_shadow(self):
|
||||
return mss.load_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||
)
|
||||
|
||||
def test_shadow_loads_before_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=3.9, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNotNone(self._load_shadow())
|
||||
|
||||
def test_shadow_loads_at_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=4.0, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNotNone(self._load_shadow())
|
||||
|
||||
def test_shadow_loads_long_after_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
record = self._load_shadow()
|
||||
self.assertIsNotNone(record)
|
||||
self.assertEqual(record.get("session_id"), self.SID)
|
||||
|
||||
def test_stale_binding_audit_record_is_also_durable(self):
|
||||
mss.save_state(
|
||||
kind=mss.KIND_STALE_BINDING_RECOVERY,
|
||||
payload={"cleared_env": "GITEA_ACTIVE_WORKTREE"},
|
||||
)
|
||||
self._age_record(mss.KIND_STALE_BINDING_RECOVERY, hours=27.0)
|
||||
self.assertIsNotNone(
|
||||
mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||
)
|
||||
|
||||
def test_non_recovery_kinds_keep_ttl(self):
|
||||
mss.save_state(
|
||||
kind=mss.KIND_WORKFLOW_LOAD, payload={"workflow_hash": "abc"}
|
||||
)
|
||||
self._age_record(mss.KIND_WORKFLOW_LOAD, hours=5.0)
|
||||
self.assertIsNone(mss.load_state(kind=mss.KIND_WORKFLOW_LOAD))
|
||||
|
||||
def test_sanctioned_clear_is_the_terminal_reconciliation(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
mss.clear_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNone(self._load_shadow())
|
||||
self.assertEqual(
|
||||
mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE), []
|
||||
)
|
||||
|
||||
def test_crash_orphan_recovery_works_after_former_ttl(self):
|
||||
# End to end: a crashed session's shadow still proves owner exit a
|
||||
# day later.
|
||||
leases.record_session_lease(
|
||||
{
|
||||
"pr_number": 701,
|
||||
"session_id": self.SID,
|
||||
"worktree": "branches/review-pr-654",
|
||||
"candidate_head": OLD_HEAD,
|
||||
"repo": REPO,
|
||||
"comment_id": 11131,
|
||||
"profile": "prgs-reviewer",
|
||||
"reviewer_identity": "sysadmin",
|
||||
"phase": "claimed",
|
||||
},
|
||||
lease_provenance={"source": "acquire", "comment_id": 11131},
|
||||
)
|
||||
leases._SESSION_LEASE = None # simulated crash: no sanctioned clear
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID)
|
||||
self.assertIsNotNone(shadow)
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow,
|
||||
lease={"session_id": self.SID},
|
||||
current_pid=os.getpid() + 1,
|
||||
pid_alive=False,
|
||||
)
|
||||
self.assertTrue(verdict["orphan_evidence"])
|
||||
self.assertIs(verdict["owner_process_alive"], False)
|
||||
|
||||
|
||||
class TestF5ConcurrentSessionShadows(unittest.TestCase):
|
||||
"""F5: concurrent same-profile sessions keep distinct shadow records."""
|
||||
|
||||
SID_A = "11111-aaaaaaaaaaaa"
|
||||
SID_B = "22222-bbbbbbbbbbbb"
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||
)
|
||||
self._env.start()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _lease(self, session_id: str, pr_number: int, head: str) -> dict:
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"session_id": session_id,
|
||||
"worktree": f"branches/review-pr-{pr_number}-{session_id[:5]}",
|
||||
"candidate_head": head,
|
||||
"repo": REPO,
|
||||
"comment_id": 11221,
|
||||
"profile": "prgs-reviewer",
|
||||
"reviewer_identity": "sysadmin",
|
||||
"phase": "claimed",
|
||||
}
|
||||
|
||||
def _record(self, lease: dict) -> None:
|
||||
leases.record_session_lease(
|
||||
lease,
|
||||
lease_provenance={"source": "acquire", "comment_id": 11221},
|
||||
)
|
||||
|
||||
def test_interleaved_sessions_do_not_overwrite_each_other(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Session A heartbeats again after B wrote.
|
||||
updated_a = self._lease(self.SID_A, 703, OLD_HEAD)
|
||||
updated_a["phase"] = "validation-start"
|
||||
self._record(updated_a)
|
||||
|
||||
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
shadow_b = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||
self.assertEqual(shadow_a.get("pr_number"), 703)
|
||||
self.assertEqual(shadow_a.get("phase"), "validation-start")
|
||||
self.assertEqual(shadow_b.get("session_id"), self.SID_B)
|
||||
self.assertEqual(shadow_b.get("pr_number"), 701)
|
||||
self.assertEqual(shadow_b.get("candidate_head"), NEW_HEAD)
|
||||
|
||||
def test_shadow_lookup_never_misattributes(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_A),
|
||||
lease={"session_id": self.SID_B},
|
||||
pid_alive=False,
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
|
||||
def test_clearing_one_session_preserves_the_other(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Sanctioned clear of B (the currently recorded in-memory lease).
|
||||
leases.clear_session_lease()
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
self.assertIsNotNone(shadow_a)
|
||||
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||
|
||||
def test_reconnected_process_recovers_by_session_id(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Simulated crash + reconnect: in-memory state is gone, durable
|
||||
# records remain enumerable and individually attributable.
|
||||
leases._SESSION_LEASE = None
|
||||
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
self.assertEqual(
|
||||
sorted(r.get("session_id") for r in records),
|
||||
sorted([self.SID_A, self.SID_B]),
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
self.assertEqual(shadow.get("pr_number"), 701)
|
||||
|
||||
def test_legacy_single_slot_record_still_resolves_by_session_id(self):
|
||||
# Upgrade path: a record written by pre-F5 code (no instance key)
|
||||
# remains usable when its payload names the requested session.
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
payload={"session_id": self.SID_A, "owner_pid": os.getpid()},
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
self.assertIsNotNone(shadow)
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
|
||||
|
||||
class TestF6AuditBeforeClear(_CapabilityHarness):
|
||||
"""F6: the durable audit record is persisted before the env clears."""
|
||||
|
||||
def _recovery_env(self, missing: str) -> dict:
|
||||
return self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
|
||||
def test_audit_write_failure_blocks_the_clear(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(
|
||||
mss, "save_state", side_effect=OSError("disk full")
|
||||
):
|
||||
report = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), missing)
|
||||
self.assertFalse(report["recovery_performed"])
|
||||
self.assertIs(report.get("audit_persisted"), False)
|
||||
self.assertTrue(report.get("audit_write_failed"))
|
||||
self.assertIn("NOT cleared", " ".join(report.get("reasons") or []))
|
||||
|
||||
def test_retry_after_audit_failure_recovers(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(
|
||||
mss, "save_state", side_effect=OSError("disk full")
|
||||
):
|
||||
first = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertFalse(first["recovery_performed"])
|
||||
# Persistence recovered; the retry clears with a durable audit.
|
||||
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
self.assertTrue(second["recovery_performed"])
|
||||
self.assertIs(second.get("audit_persisted"), True)
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
audit = mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||
self.assertIsNotNone(audit)
|
||||
self.assertEqual(audit.get("recovery_status"), "performed")
|
||||
self.assertEqual(audit.get("cleared_value"), missing)
|
||||
|
||||
def test_audit_record_exists_before_env_clear(self):
|
||||
missing = self._deleted_worktree()
|
||||
observed: list[tuple[str | None, str | None]] = []
|
||||
real_save = mss.save_state
|
||||
|
||||
def _spy(**kwargs):
|
||||
observed.append(
|
||||
(
|
||||
(kwargs.get("payload") or {}).get("recovery_status"),
|
||||
os.environ.get("GITEA_ACTIVE_WORKTREE"),
|
||||
)
|
||||
)
|
||||
return real_save(**kwargs)
|
||||
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(mss, "save_state", side_effect=_spy):
|
||||
report = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertTrue(report["recovery_performed"])
|
||||
self.assertGreaterEqual(len(observed), 2)
|
||||
pending_status, env_at_pending = observed[0]
|
||||
performed_status, env_at_performed = observed[-1]
|
||||
# Ordering proof: the binding was still present while the pending
|
||||
# audit persisted, and gone by the time the performed status wrote.
|
||||
self.assertEqual(pending_status, "pending")
|
||||
self.assertEqual(env_at_pending, missing)
|
||||
self.assertEqual(performed_status, "performed")
|
||||
self.assertIsNone(env_at_performed)
|
||||
|
||||
def test_recovery_is_idempotent_after_success(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
first = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
self.assertTrue(first["recovery_performed"])
|
||||
self.assertEqual(second["classification"], sbr.CLASSIFICATION_UNBOUND)
|
||||
self.assertFalse(second["recovery_performed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Regression tests for #702: stale runtime binding + orphaned durable lease.
|
||||
|
||||
Reproduces the PR #701 incident (lease 65664-4f248d213d60, comments
|
||||
11129/11131): the MCP daemon crashed without teardown, destroying the
|
||||
in-memory session lease while the durable comment lease survived, and the
|
||||
auto-reconnected daemon inherited a stale ``GITEA_ACTIVE_WORKTREE`` binding
|
||||
(``branches/review-pr-654``) from its parent environment.
|
||||
|
||||
Covers the five mandated scenarios:
|
||||
|
||||
1. lost in-session leases — durable shadow survives a crash and yields
|
||||
provable owner-process evidence
|
||||
2. old-head leases — expired superseded-head lease with no terminal
|
||||
review becomes recoverable only with orphan evidence + controller authority
|
||||
3. runtime/worktree mismatch — env bindings to deleted worktrees are demoted,
|
||||
never silently bound
|
||||
4. managed reconnect — boot-inherited uncorroborated bindings are
|
||||
surfaced for managed recovery; provably stale ones are clear-eligible
|
||||
5. safe expiry — pre-expiry stays fail-closed wait (no steal);
|
||||
canonical expiry unlocks acquisition and guarded cleanup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import reviewer_pr_lease as leases # noqa: E402
|
||||
import stale_binding_recovery as sbr # noqa: E402
|
||||
|
||||
# PR #701 incident fixtures.
|
||||
OLD_HEAD = "b4d0cb22e452a07c8e816745c704ddb0f43edf0b"
|
||||
NEW_HEAD = "6b675f5c834b41f9d74e8a54294ff44dddf28ae4"
|
||||
CRASHED_SESSION = "65664-4f248d213d60"
|
||||
LEASE_COMMENT = 11131
|
||||
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
PR = 701
|
||||
ISSUE = 699
|
||||
LEASE_WORKTREE = "branches/review-fix-issue-699-structured-auth-mcp-errors"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _lease_comment(
|
||||
*,
|
||||
phase: str = "validation-start",
|
||||
candidate_head: str = OLD_HEAD,
|
||||
session_id: str = CRASHED_SESSION,
|
||||
comment_id: int = LEASE_COMMENT,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
worktree: str = LEASE_WORKTREE,
|
||||
) -> dict:
|
||||
stamp = last_activity or _now()
|
||||
body = leases.format_lease_body(
|
||||
repo=REPO,
|
||||
pr_number=PR,
|
||||
issue_number=ISSUE,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id=session_id,
|
||||
worktree=worktree,
|
||||
phase=phase,
|
||||
candidate_head=candidate_head,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
last_activity=stamp,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return {
|
||||
"id": comment_id,
|
||||
"body": body,
|
||||
"user": {"login": "sysadmin"},
|
||||
"author": "sysadmin",
|
||||
"created_at": stamp.isoformat(),
|
||||
"updated_at": stamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _expired_superseded_comments() -> list[dict]:
|
||||
stale = _now() - timedelta(hours=3)
|
||||
return [_lease_comment(
|
||||
last_activity=stale,
|
||||
expires_at=stale + timedelta(minutes=120),
|
||||
)]
|
||||
|
||||
|
||||
def _fresh_superseded_comments() -> list[dict]:
|
||||
recent = _now() - timedelta(minutes=10)
|
||||
return [_lease_comment(
|
||||
last_activity=recent,
|
||||
expires_at=recent + timedelta(minutes=120),
|
||||
)]
|
||||
|
||||
|
||||
class TestLostInSessionLeaseShadow(unittest.TestCase):
|
||||
"""Scenario 1: the durable shadow survives a daemon crash (#702 AC3)."""
|
||||
|
||||
def setUp(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _record(self) -> dict:
|
||||
return leases.record_session_lease(
|
||||
{
|
||||
"pr_number": PR,
|
||||
"issue_number": ISSUE,
|
||||
"session_id": CRASHED_SESSION,
|
||||
"reviewer_identity": "sysadmin",
|
||||
"profile": "prgs-reviewer",
|
||||
"worktree": LEASE_WORKTREE,
|
||||
"phase": "claimed",
|
||||
"candidate_head": OLD_HEAD,
|
||||
"repo": REPO,
|
||||
"comment_id": LEASE_COMMENT,
|
||||
},
|
||||
lease_provenance={"source": "acquire", "comment_id": LEASE_COMMENT},
|
||||
)
|
||||
|
||||
def test_shadow_written_on_record_and_survives_simulated_crash(self):
|
||||
self._record()
|
||||
# Simulated unhandled crash: the in-memory dict dies with the process
|
||||
# but the sanctioned clear path never runs.
|
||||
leases._SESSION_LEASE = None
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
self.assertIsNotNone(shadow)
|
||||
self.assertEqual(shadow.get("session_id"), CRASHED_SESSION)
|
||||
self.assertEqual(shadow.get("pr_number"), PR)
|
||||
self.assertEqual(int(shadow.get("owner_pid")), os.getpid())
|
||||
|
||||
def test_sanctioned_clear_removes_shadow(self):
|
||||
self._record()
|
||||
leases.clear_session_lease()
|
||||
self.assertIsNone(leases.load_session_lease_shadow())
|
||||
|
||||
def test_orphan_assessment_dead_owner_pid_proves_exit(self):
|
||||
self._record()
|
||||
leases._SESSION_LEASE = None
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
lease = {"session_id": CRASHED_SESSION}
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow, lease=lease, current_pid=os.getpid() + 1, pid_alive=False
|
||||
)
|
||||
self.assertTrue(verdict["orphan_evidence"])
|
||||
self.assertIs(verdict["owner_process_alive"], False)
|
||||
|
||||
def test_orphan_assessment_alive_pid_is_not_ownership_proof(self):
|
||||
self._record()
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow,
|
||||
lease={"session_id": CRASHED_SESSION},
|
||||
current_pid=os.getpid() + 1,
|
||||
pid_alive=True,
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
def test_orphan_assessment_session_mismatch_proves_nothing(self):
|
||||
self._record()
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow, lease={"session_id": "other-999"}, pid_alive=False
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
def test_orphan_assessment_without_shadow_is_unknown(self):
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
None, lease={"session_id": CRASHED_SESSION}
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
|
||||
class TestOldHeadLeaseCleanup(unittest.TestCase):
|
||||
"""Scenario 2: expired superseded-head lease with no terminal review."""
|
||||
|
||||
def _assess(self, comments, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=PR,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
worktree_exists=True,
|
||||
worktree_clean=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
comments, **defaults
|
||||
)
|
||||
|
||||
def test_expired_orphan_with_full_evidence_is_cleanup_eligible(self):
|
||||
result = self._assess(_expired_superseded_comments())
|
||||
self.assertEqual(result["classification"], "orphaned_expired_superseded_head")
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
self.assertIn("phase: released", result["release_body"] or "")
|
||||
self.assertEqual(
|
||||
result["exact_next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||
)
|
||||
|
||||
def test_expired_orphan_without_owner_evidence_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(), owner_process_alive=None
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertIn(
|
||||
"provable owner-process-exit evidence",
|
||||
" ".join(result["fail_closed_reasons"]),
|
||||
)
|
||||
|
||||
def test_expired_orphan_without_controller_authority_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(), controller_recovery_authorized=False
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_expired_orphan_with_dirty_worktree_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(),
|
||||
worktree_clean=False,
|
||||
worktree_has_unpreserved_work=True,
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_unexpired_superseded_no_terminal_stays_ambiguous_wait(self):
|
||||
# Regression guard: pre-expiry there is still no steal path.
|
||||
result = self._assess(_fresh_superseded_comments())
|
||||
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertEqual(result["exact_next_action"], "wait")
|
||||
|
||||
|
||||
class TestRuntimeWorktreeMismatch(unittest.TestCase):
|
||||
"""Scenario 3: env bindings to deleted worktrees are demoted (#702 AC2)."""
|
||||
|
||||
def test_missing_active_env_binding_is_demoted(self):
|
||||
demotions: list[str] = []
|
||||
missing = "/nonexistent/branches/review-pr-654"
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": missing},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(len(demotions), 1)
|
||||
self.assertIn("no longer exists", demotions[0])
|
||||
|
||||
def test_missing_active_falls_through_to_existing_role_env(self):
|
||||
existing = os.getcwd()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root="/tmp",
|
||||
env={
|
||||
"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654",
|
||||
"GITEA_REVIEWER_WORKTREE": existing,
|
||||
},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(existing))
|
||||
self.assertEqual(source, "GITEA_REVIEWER_WORKTREE environment variable")
|
||||
|
||||
def test_existing_active_env_binding_still_wins(self):
|
||||
existing = os.getcwd()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root="/tmp",
|
||||
env={"GITEA_ACTIVE_WORKTREE": existing},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(existing))
|
||||
self.assertEqual(source, "GITEA_ACTIVE_WORKTREE environment variable")
|
||||
|
||||
def test_explicit_worktree_path_argument_is_never_demoted(self):
|
||||
missing = "/nonexistent/branches/explicit"
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
worktree_path=missing,
|
||||
process_project_root="/tmp",
|
||||
env={},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "worktree_path argument")
|
||||
|
||||
def test_mutation_context_reports_demotion_in_ignored_bindings(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="reviewer",
|
||||
worktree_path=None,
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"},
|
||||
)
|
||||
joined = " ".join(ctx["ignored_bindings"])
|
||||
self.assertIn("stale binding, #702", joined)
|
||||
|
||||
|
||||
class TestManagedReconnectRecovery(unittest.TestCase):
|
||||
"""Scenario 4: boot-inherited bindings and the sanctioned recovery plan."""
|
||||
|
||||
def test_uncorroborated_inherited_reviewer_binding_is_unverified(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
role_env_value=None,
|
||||
session_lease_worktree=None,
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||
)
|
||||
self.assertFalse(classification["clear_eligible"])
|
||||
plan = sbr.plan_recovery(classification)
|
||||
self.assertFalse(plan["clear_allowed"])
|
||||
self.assertIn("managed", plan["exact_next_action"])
|
||||
self.assertIn("do not clear or rewrite", plan["exact_next_action"])
|
||||
|
||||
def test_missing_path_binding_is_provably_stale_and_clear_eligible(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/nonexistent/branches/review-pr-654",
|
||||
boot_inherited=True,
|
||||
path_exists=False,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"],
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
plan = sbr.plan_recovery(classification)
|
||||
self.assertTrue(plan["clear_allowed"])
|
||||
|
||||
def test_inherited_binding_superseded_by_session_lease_is_clear_eligible(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/tmp",
|
||||
session_lease_worktree=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"],
|
||||
sbr.CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
)
|
||||
self.assertTrue(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_post_boot_binding_conflict_is_surfaced_not_cleared(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/tmp",
|
||||
session_lease_worktree=os.getcwd(),
|
||||
boot_inherited=False,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||
)
|
||||
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_corroborated_bindings_are_untouched(self):
|
||||
for kwargs in (
|
||||
dict(role_env_value=os.getcwd()),
|
||||
dict(session_lease_worktree=os.getcwd()),
|
||||
):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
**kwargs,
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||
)
|
||||
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_author_inherited_binding_stays_corroborated(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="author",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||
)
|
||||
|
||||
def test_apply_recovery_clears_env_only_for_authorized_plan(self):
|
||||
env = {"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"}
|
||||
plan = sbr.plan_recovery(
|
||||
sbr.classify_active_worktree_binding(
|
||||
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||
boot_inherited=True,
|
||||
path_exists=False,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
)
|
||||
applied = sbr.apply_recovery(plan, env=env)
|
||||
self.assertTrue(applied["performed"])
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", env)
|
||||
self.assertIn("review-pr-654", applied["cleared_value"])
|
||||
|
||||
def test_apply_recovery_refuses_unauthorized_plan(self):
|
||||
env = {"GITEA_ACTIVE_WORKTREE": os.getcwd()}
|
||||
plan = sbr.plan_recovery(
|
||||
sbr.classify_active_worktree_binding(
|
||||
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
)
|
||||
applied = sbr.apply_recovery(plan, env=env)
|
||||
self.assertFalse(applied["performed"])
|
||||
self.assertIn("GITEA_ACTIVE_WORKTREE", env)
|
||||
|
||||
|
||||
class TestSafeExpiryDiagnosis(unittest.TestCase):
|
||||
"""Scenario 5: canonical expiry flips wait into acquire/guarded cleanup."""
|
||||
|
||||
def _diagnose(self, comments, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=PR,
|
||||
current_session_id=None,
|
||||
current_reviewer_identity="fresh-reviewer",
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.diagnose_reviewer_pr_lease_handoff(comments, **defaults)
|
||||
|
||||
def setUp(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def test_pre_expiry_superseded_no_terminal_stays_wait(self):
|
||||
diagnosis = self._diagnose(_fresh_superseded_comments())
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "ambiguous_conflicting_evidence"
|
||||
)
|
||||
self.assertEqual(diagnosis["next_action"], "wait")
|
||||
|
||||
def test_post_expiry_without_orphan_evidence_unlocks_acquire(self):
|
||||
diagnosis = self._diagnose(_expired_superseded_comments())
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertEqual(diagnosis["next_action"], "acquire")
|
||||
|
||||
def test_post_expiry_with_orphan_evidence_offers_guarded_cleanup(self):
|
||||
diagnosis = self._diagnose(
|
||||
_expired_superseded_comments(),
|
||||
owner_process_alive=False,
|
||||
worktree_clean=True,
|
||||
worktree_exists=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertEqual(
|
||||
diagnosis["next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||
)
|
||||
|
||||
def test_expired_lease_no_longer_blocks_fresh_acquisition(self):
|
||||
assessment = leases.assess_acquire_lease(
|
||||
_expired_superseded_comments(),
|
||||
pr_number=PR,
|
||||
reviewer_identity="fresh-reviewer",
|
||||
profile="prgs-reviewer",
|
||||
session_id="99999-fresh",
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
worktree="branches/review-pr-701-fresh",
|
||||
candidate_head=NEW_HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
)
|
||||
self.assertTrue(assessment.get("acquire_allowed"))
|
||||
|
||||
def test_unparseable_expiry_fails_closed_in_cleanup(self):
|
||||
comment = _lease_comment(
|
||||
last_activity=_now() - timedelta(hours=3), expires_at=None
|
||||
)
|
||||
comment["body"] = comment["body"].replace(
|
||||
"expires_at: ", "expires_at: not-a-timestamp"
|
||||
)
|
||||
result = leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
[comment],
|
||||
pr_number=PR,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
worktree_exists=True,
|
||||
worktree_clean=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2540,18 +2540,15 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||
_save_review_decision_lock(lock)
|
||||
with patch("mcp_server.api_request", return_value={"id": 9001}):
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
self.assertTrue(r["authorized"])
|
||||
lock = _load_review_decision_lock()
|
||||
self.assertTrue(lock["correction_authorized"])
|
||||
self.assertEqual(lock["correction_pr_number"], 8)
|
||||
|
||||
def test_authorize_review_correction_mismatch(self):
|
||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||
@@ -2565,7 +2562,6 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
||||
@@ -2576,22 +2572,10 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="request_changes",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
||||
|
||||
# Cross-PR unlock refused (#693)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="unlock other pr",
|
||||
operator_authorized=True,
|
||||
target_pr_number=692,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||
|
||||
def test_authorize_review_correction_requires_operator_auth(self):
|
||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||
lock = _load_review_decision_lock() or {}
|
||||
@@ -2604,7 +2588,6 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=False,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
||||
@@ -2662,7 +2645,6 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="operator approved correcting mistaken approve",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
_mark_request_changes_ready(remote="prgs")
|
||||
second = gitea_submit_pr_review(
|
||||
|
||||
@@ -188,32 +188,20 @@ class TestActiveHardStopPreserved(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self):
|
||||
_seed([APPROVED_OPEN]) # approve on PR #700
|
||||
# Same PR still hard-stopped for mark_ready.
|
||||
reasons_same = mcp_server.terminal_review_hard_stop_reasons(
|
||||
700, "mark_ready"
|
||||
)
|
||||
self.assertTrue(reasons_same)
|
||||
self.assertIn("#332", reasons_same[0])
|
||||
# #693: other PR may mark_ready without cleanup.
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||
[],
|
||||
)
|
||||
def test_open_approve_still_blocks_other_pr_mark_ready(self):
|
||||
_seed([APPROVED_OPEN])
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
|
||||
|
||||
def test_request_changes_still_blocks_same_pr(self):
|
||||
_seed([RC_OPEN]) # request_changes on PR #701
|
||||
def test_request_changes_still_blocks_everything(self):
|
||||
_seed([RC_OPEN])
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(701, op),
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
||||
msg=op,
|
||||
)
|
||||
# Foreign PR review path is open (#693).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
||||
|
||||
Extends the single-terminal-decision machinery (#211): after a live
|
||||
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
|
||||
only the merge sequence for that same PR may continue; #693 allows a
|
||||
*different* PR to be mark_ready/review in the same durable lock (cross-PR
|
||||
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
|
||||
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
|
||||
for that same PR may continue; a different PR can never be reviewed or
|
||||
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
|
||||
suppressed.
|
||||
"""
|
||||
import os
|
||||
import unittest
|
||||
@@ -15,10 +15,7 @@ import mcp_server
|
||||
HEAD_SHA = "a" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False, correction_pr=None):
|
||||
mutations = list(mutations or [])
|
||||
if correction and correction_pr is None and mutations:
|
||||
correction_pr = mutations[-1].get("pr_number")
|
||||
def _lock(mutations=None, correction=False):
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -32,11 +29,9 @@ def _lock(mutations=None, correction=False, correction_pr=None):
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": mutations,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": correction_pr if correction else None,
|
||||
"correction_head_sha": None,
|
||||
"correction_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -69,35 +64,25 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
|
||||
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||
def test_request_changes_blocks_everything(self):
|
||||
_seed([RC_A])
|
||||
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
|
||||
self.assertTrue(reasons, f"{op}/5")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
# Merge of an unapproved other PR remains blocked by hard-stop path
|
||||
# (last terminal is not approve of PR 6).
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
for pr in (5, 6):
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
|
||||
self.assertTrue(reasons, f"{op}/{pr}")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
|
||||
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||
def test_approve_allows_same_pr_merge_only(self):
|
||||
_seed([APPROVED_A])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
# #693 cross-PR isolation: other PR formal review path is open.
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
||||
|
||||
def test_correction_reopens_review_path_only(self):
|
||||
_seed([RC_A], correction=True)
|
||||
@@ -105,11 +90,9 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
||||
# scoped correction never opens a cross-PR merge
|
||||
# correction never opens a cross-PR merge
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
# unscoped/cross-PR correction must not lift PR 6 via correction alone
|
||||
# (PR 6 is allowed by isolation, not by correction scope)
|
||||
|
||||
|
||||
class TestMergeHardStopWiring(unittest.TestCase):
|
||||
@@ -143,29 +126,14 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||
_seed([RC_A])
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=5, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
pr_number=6, action="approve", remote="prgs")
|
||||
self.assertFalse(result["marked_ready"])
|
||||
self.assertTrue(
|
||||
any("#332" in r for r in result["reasons"]), result["reasons"])
|
||||
|
||||
def test_mark_ready_other_pr_allowed_after_foreign_terminal(self):
|
||||
"""#693: foreign open-PR terminal must not block mark_final on PR B."""
|
||||
_seed([RC_A])
|
||||
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
||||
patch("mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=no_lease), \
|
||||
patch.object(mcp_server, "gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_SHA, "eligible": True}):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
|
||||
def _feedback(blocking, stale=False, success=True):
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user