Resolve task_capability_map.py by unioning #850 bootstrap_author_issue_worktree preflight transitions with master's #860 dirty-orphan recovery and work_issue commit transitions. No feature logic discarded. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+581
-20
@@ -2076,6 +2076,7 @@ import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery
|
||||
import dirty_same_claimant_session_rebind # noqa: E402 # #864
|
||||
import stacked_pr_support # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
@@ -2471,6 +2472,20 @@ def _evaluate_issue_lock_recovery(
|
||||
descendant_sha=local_head,
|
||||
)
|
||||
|
||||
# #871: the inverse of the #768 descendant relation — the *remote* head may
|
||||
# have advanced past the local/recorded head via a sanctioned merge-based
|
||||
# branch sync (``gitea_update_pr_branch_by_merge``) while the local worktree
|
||||
# stayed put. Observe that provenance server-side so the assessor can prove
|
||||
# it and nothing else. Probed only when the heads differ; never from any
|
||||
# caller-supplied value.
|
||||
sync_provenance: dict | None = None
|
||||
if remote_head and local_head and remote_head != local_head:
|
||||
sync_provenance = issue_lock_worktree.read_merge_sync_provenance(
|
||||
worktree_path,
|
||||
prior_head_sha=local_head,
|
||||
synced_head_sha=remote_head,
|
||||
)
|
||||
|
||||
# #772: with no remote branch there is no head to measure against, so the
|
||||
# base the branch was cut from is observed instead. Probed only in that
|
||||
# case, so the published path's evidence is untouched (#772 AC8).
|
||||
@@ -2513,6 +2528,7 @@ def _evaluate_issue_lock_recovery(
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
recorded_base_sha=recorded_base,
|
||||
base_ancestry=base_ancestry,
|
||||
sync_provenance=sync_provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -4388,6 +4404,280 @@ def gitea_lock_issue(
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool()
|
||||
def gitea_recover_dirty_orphaned_issue_worktree(
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
source_worktree_path: str,
|
||||
expected_local_head: str,
|
||||
expected_remote_head: str,
|
||||
expected_dirty_fingerprints: dict,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
recovery_worktree_path: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Recover a dirty orphaned same-claimant author issue worktree (#860).
|
||||
|
||||
Explicit recovery operation — does **not** silently widen ``gitea_lock_issue``.
|
||||
|
||||
Accepts authoritative expected pins (repository, issue, branch, source
|
||||
worktree, claimant, local head, remote/PR head, dirty fingerprints) and
|
||||
fails closed on any mismatch. PID-less malformed locks are never treated
|
||||
as live merely because expiry is absent. The source worktree is frozen;
|
||||
recovery prepares a separate worktree at the pinned remote head, re-applies
|
||||
dirty bytes with path-level conflict detection, and binds a live author
|
||||
session only after recovery state is consistent.
|
||||
|
||||
Args:
|
||||
issue_number: Issue whose durable claim is being recovered.
|
||||
branch_name: Locked branch ``(fix|feat|docs|chore)/issue-N-…``.
|
||||
source_worktree_path: Registered dirty source worktree under branches/.
|
||||
expected_local_head: Full 40-char SHA of the source worktree HEAD.
|
||||
expected_remote_head: Full 40-char SHA of the remote/PR head to sync to.
|
||||
expected_dirty_fingerprints: ``{relative_path: sha256}`` of dirty bytes.
|
||||
remote/host/org/repo: Repository binding.
|
||||
recovery_worktree_path: Optional recovery worktree path under branches/.
|
||||
dry_run: Assess eligibility only; no filesystem or lock mutation.
|
||||
|
||||
Returns:
|
||||
dict with success, outcome, conflicts, recovery_worktree_path, reasons,
|
||||
evidence, and journal metadata.
|
||||
"""
|
||||
task = "recover_dirty_orphaned_issue_worktree"
|
||||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||
task
|
||||
)
|
||||
if not ok:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"outcome": "REFUSED",
|
||||
"reasons": block_reasons,
|
||||
}
|
||||
blocked = _namespace_mutation_block(task, remote=remote)
|
||||
if blocked:
|
||||
return blocked
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission(task),
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
org_explicit=org is not None,
|
||||
repo_explicit=repo is not None,
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
profile_meta = get_profile() or {}
|
||||
identity = (_authenticated_username(h) or "").strip()
|
||||
profile = (profile_meta.get("profile_name") or "").strip()
|
||||
if not identity or not profile:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"outcome": "REFUSED",
|
||||
"reasons": ["could not resolve authenticated identity/profile"],
|
||||
}
|
||||
|
||||
existing_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
|
||||
src = os.path.realpath(source_worktree_path)
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(src)
|
||||
observed_local = (git_state.get("head_sha") or "").strip()
|
||||
porcelain = git_state.get("porcelain_status") or ""
|
||||
current_branch = git_state.get("current_branch")
|
||||
|
||||
# Observed dirty fingerprints from source worktree bytes.
|
||||
observed_fps: dict[str, str] = {}
|
||||
dirty_contents: dict[str, bytes] = {}
|
||||
for rel in (expected_dirty_fingerprints or {}):
|
||||
rel_n = str(rel).strip()
|
||||
fpath = os.path.join(src, rel_n)
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath, "rb") as fh:
|
||||
data = fh.read()
|
||||
dirty_contents[rel_n] = data
|
||||
observed_fps[rel_n] = dirty_orphan_worktree_recovery.sha256_bytes(data)
|
||||
|
||||
# Remote head observation (best-effort; pin mismatch fails closed).
|
||||
observed_remote = ""
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["git", "ls-remote", remote or "prgs", f"refs/heads/{branch_name}"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode == 0 and (probe.stdout or "").strip():
|
||||
observed_remote = (probe.stdout or "").strip().split()[0]
|
||||
except Exception:
|
||||
observed_remote = ""
|
||||
|
||||
registered = False
|
||||
try:
|
||||
listing = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if listing.returncode == 0:
|
||||
registered = src in (listing.stdout or "")
|
||||
except Exception:
|
||||
registered = False
|
||||
|
||||
project_root = _canonical_local_git_root()
|
||||
canonical_root = author_mutation_worktree.resolve_canonical_repo_root(
|
||||
src, project_root
|
||||
)
|
||||
|
||||
competing_locks: list[dict] = []
|
||||
try:
|
||||
all_live = issue_lock_store.list_live_locks()
|
||||
for l in all_live:
|
||||
if l.get("issue_number") == issue_number:
|
||||
wt = l.get("worktree_path")
|
||||
if not wt or not issue_lock_store._same_realpath(wt, src):
|
||||
competing_locks.append(l)
|
||||
except Exception:
|
||||
competing_locks = []
|
||||
|
||||
wf_active = False
|
||||
wf_expired = True
|
||||
try:
|
||||
db, _ = _control_plane_db_or_error()
|
||||
if db is not None:
|
||||
active_leases_data = lease_lifecycle.list_active_leases(
|
||||
db,
|
||||
remote=remote if remote in REMOTES else remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
)
|
||||
leases_list = active_leases_data.get("leases") or []
|
||||
for l in leases_list:
|
||||
if l.get("work_number") == issue_number and l.get("work_kind") == "issue":
|
||||
fresh = l.get("freshness") or {}
|
||||
if fresh.get("status") == "active":
|
||||
wf_active = True
|
||||
wf_expired = False
|
||||
elif fresh.get("status") in ("expired", "stale_dead_process"):
|
||||
wf_active = False
|
||||
wf_expired = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery(
|
||||
existing_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
source_worktree_path=src,
|
||||
remote=remote if remote else "prgs",
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=identity,
|
||||
profile=profile,
|
||||
expected_local_head=expected_local_head,
|
||||
expected_remote_head=expected_remote_head,
|
||||
expected_dirty_fingerprints=expected_dirty_fingerprints or {},
|
||||
current_branch=current_branch,
|
||||
porcelain_status=porcelain,
|
||||
observed_local_head=observed_local,
|
||||
observed_remote_head=observed_remote,
|
||||
observed_dirty_fingerprints=observed_fps,
|
||||
competing_live_locks=competing_locks,
|
||||
competing_live_sessions=[],
|
||||
workflow_lease_active=wf_active,
|
||||
workflow_lease_expired=wf_expired,
|
||||
canonical_repo_root=canonical_root,
|
||||
worktree_registered=registered,
|
||||
current_pid=os.getpid(),
|
||||
)
|
||||
if dry_run or not assessment.get("eligible"):
|
||||
return {
|
||||
"success": bool(assessment.get("eligible")),
|
||||
"performed": False,
|
||||
"dry_run": dry_run,
|
||||
"outcome": assessment.get("outcome"),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"evidence": dict(assessment.get("evidence") or {}),
|
||||
"eligible": bool(assessment.get("eligible")),
|
||||
}
|
||||
|
||||
if not recovery_worktree_path:
|
||||
recovery_worktree_path = os.path.join(
|
||||
canonical_root,
|
||||
"branches",
|
||||
f"recovery-issue-{issue_number}-dirty-orphan",
|
||||
)
|
||||
|
||||
# Load blob contents at local/remote heads for conflict detection.
|
||||
def _blob_at(head: str, rel: str) -> bytes | None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "show", f"{head}:{rel}"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
local_contents = {
|
||||
rel: _blob_at(expected_local_head, rel)
|
||||
for rel in (expected_dirty_fingerprints or {})
|
||||
}
|
||||
remote_contents = {
|
||||
rel: _blob_at(expected_remote_head, rel)
|
||||
for rel in (expected_dirty_fingerprints or {})
|
||||
}
|
||||
|
||||
# Preflight purity is satisfied via explicit worktree_path on this tool's
|
||||
# recovery path; source remains frozen and is never cleaned.
|
||||
result = dirty_orphan_worktree_recovery.run_dirty_orphan_recovery(
|
||||
assessment=assessment,
|
||||
existing_lock=existing_lock or {},
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
source_worktree_path=src,
|
||||
recovery_worktree_path=recovery_worktree_path,
|
||||
remote=remote if remote else "prgs",
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=identity,
|
||||
profile=profile,
|
||||
expected_local_head=expected_local_head,
|
||||
expected_remote_head=expected_remote_head,
|
||||
expected_dirty_fingerprints=expected_dirty_fingerprints or {},
|
||||
dirty_contents=dirty_contents,
|
||||
local_head_contents=local_contents,
|
||||
remote_head_contents=remote_contents,
|
||||
canonical_repo_root=canonical_root,
|
||||
bind_lock=True,
|
||||
session_pid=os.getpid(),
|
||||
)
|
||||
# Surface preflight recognition for recovered provenance.
|
||||
if result.get("success") and result.get("lock_record"):
|
||||
result["preflight_provenance"] = (
|
||||
dirty_orphan_worktree_recovery.preflight_recognizes_recovered_provenance(
|
||||
result["lock_record"]
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_rebind_dirty_same_claimant_author_session(
|
||||
issue_number: int,
|
||||
@@ -4644,6 +4934,8 @@ def gitea_rebind_dirty_same_claimant_author_session(
|
||||
}
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_work_issue_duplicate(
|
||||
@@ -11269,6 +11561,9 @@ def _collect_branch_ownership_records(
|
||||
"""
|
||||
records: list[dict] = []
|
||||
inventory_error = False
|
||||
# #855 AC4: expired/stale reviewer-lease records eligible for an explicit
|
||||
# reclaim decision, evaluated after the full ownership inventory is built.
|
||||
reviewer_reclaim_candidates: list[tuple[dict, bool | None]] = []
|
||||
target_branch = (branch or "").strip()
|
||||
if not target_branch:
|
||||
return {"records": records, "inventory_error": False}
|
||||
@@ -11417,15 +11712,28 @@ def _collect_branch_ownership_records(
|
||||
else:
|
||||
status = freshness_status
|
||||
reclaim_allowed = False
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=category,
|
||||
status=status,
|
||||
reclaim_allowed=reclaim_allowed,
|
||||
role=role,
|
||||
host=lease_host or host_n or host,
|
||||
)
|
||||
rec = _base_rec(
|
||||
category=category,
|
||||
status=status,
|
||||
reclaim_allowed=reclaim_allowed,
|
||||
role=role,
|
||||
host=lease_host or host_n or host,
|
||||
)
|
||||
records.append(rec)
|
||||
# #855 AC4: a reviewer lease that is expired/stale (its owner
|
||||
# gone) becomes a candidate for an explicit, fail-closed
|
||||
# reclaim decision made once the full inventory is known.
|
||||
if (
|
||||
role == "reviewer"
|
||||
and status
|
||||
in branch_cleanup_guard._RECLAIMABLE_REVIEWER_STATUSES
|
||||
):
|
||||
owner_alive = (
|
||||
fr.get("owner_pid_alive") if isinstance(fr, dict) else None
|
||||
)
|
||||
reviewer_reclaim_candidates.append(
|
||||
(rec, owner_alive if isinstance(owner_alive, bool) else None)
|
||||
)
|
||||
except Exception:
|
||||
# O1: fail closed on control-plane inventory errors.
|
||||
inventory_error = True
|
||||
@@ -11496,6 +11804,44 @@ def _collect_branch_ownership_records(
|
||||
)
|
||||
)
|
||||
|
||||
# #855 AC4: decide, explicitly and fail-closed, whether any expired/stale
|
||||
# reviewer lease may stop protecting an already-merged branch. This runs
|
||||
# only after the full ownership inventory is built, so a competing active
|
||||
# claimant (an active lease, author session, worktree binding, or active
|
||||
# reviewer comment lease) is visible. An inventory failure keeps every
|
||||
# reclaim candidate protective (reclaim_allowed stays False).
|
||||
if reviewer_reclaim_candidates and not inventory_error:
|
||||
pr_merged_state: bool | None = None
|
||||
if pr_number is not None and auth and base_api:
|
||||
try:
|
||||
pr_live = api_request(
|
||||
"GET", f"{base_api}/pulls/{int(pr_number)}", auth
|
||||
)
|
||||
if isinstance(pr_live, dict) and pr_live:
|
||||
pr_merged_state = bool(
|
||||
pr_live.get("merged") or pr_live.get("merged_at")
|
||||
)
|
||||
except Exception:
|
||||
# Unknown merged state fails closed (candidate stays protective).
|
||||
pr_merged_state = None
|
||||
for cand_rec, owner_alive in reviewer_reclaim_candidates:
|
||||
competing = any(
|
||||
other is not cand_rec
|
||||
and branch_cleanup_guard.is_active_ownership_status(
|
||||
other.get("status")
|
||||
)
|
||||
for other in records
|
||||
)
|
||||
decision = branch_cleanup_guard.assess_expired_reviewer_lease_reclaim(
|
||||
role=str(cand_rec.get("role")),
|
||||
status=str(cand_rec.get("status")),
|
||||
pr_merged=pr_merged_state,
|
||||
owner_pid_alive=owner_alive,
|
||||
competing_active_claimant=competing,
|
||||
)
|
||||
cand_rec["reclaim_allowed"] = decision["reclaim_allowed"]
|
||||
cand_rec["reclaim_decision"] = decision["decision"]
|
||||
|
||||
return {"records": records, "inventory_error": inventory_error}
|
||||
|
||||
|
||||
@@ -11558,6 +11904,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
dry_run: bool = True,
|
||||
execute_confirmed: bool = False,
|
||||
limit: int = 50,
|
||||
pr_number: int | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
@@ -11568,7 +11915,11 @@ def gitea_reconcile_merged_cleanups(
|
||||
Args:
|
||||
dry_run: Defaults to True. When True, only builds the reconciliation report.
|
||||
execute_confirmed: Must be True when dry_run=False.
|
||||
limit: Max number of closed PRs to inspect.
|
||||
limit: Max number of closed PRs to inspect (batch mode only; ignored when
|
||||
``pr_number`` is set).
|
||||
pr_number: Optional exact merged PR selector (#855). When set, only that
|
||||
PR is assessed/acted on (fail closed if missing, unmerged, or
|
||||
ambiguous). When omitted, existing batch behaviour is preserved.
|
||||
remote: Known Gitea instance ('dadeschools' or 'prgs').
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
@@ -11603,11 +11954,120 @@ def gitea_reconcile_merged_cleanups(
|
||||
"audit_phase": audit_reconciliation_mode.current_phase(),
|
||||
}
|
||||
|
||||
# #855: optional exact PR pin. Fail closed before any inventory mutation.
|
||||
exact_pr: int | None = None
|
||||
if pr_number is not None:
|
||||
try:
|
||||
exact_pr = int(pr_number)
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": "exact_pr",
|
||||
"selected_pr_number": pr_number,
|
||||
"reasons": [
|
||||
f"pr_number={pr_number!r} is not a valid integer "
|
||||
"(fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "invalid_pr_number",
|
||||
}
|
||||
if exact_pr <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": "exact_pr",
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"pr_number={exact_pr} must be a positive integer "
|
||||
"(fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "invalid_pr_number",
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
|
||||
selection_mode = "batch"
|
||||
closed_prs: list[dict] = []
|
||||
open_prs: list[dict] = []
|
||||
if exact_pr is not None:
|
||||
selection_mode = "exact_pr"
|
||||
try:
|
||||
pr_live = api_request("GET", f"{base}/pulls/{exact_pr}", auth)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": selection_mode,
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"PR #{exact_pr} could not be uniquely resolved "
|
||||
f"(fail closed; no mutation): {_redact(str(exc))}"
|
||||
],
|
||||
"blocker_kind": "pr_unresolvable",
|
||||
}
|
||||
if not isinstance(pr_live, dict) or not pr_live:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": selection_mode,
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"PR #{exact_pr} could not be uniquely resolved "
|
||||
"(empty response; fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "pr_unresolvable",
|
||||
}
|
||||
live_number = pr_live.get("number")
|
||||
try:
|
||||
live_number_int = int(live_number) if live_number is not None else None
|
||||
except (TypeError, ValueError):
|
||||
live_number_int = None
|
||||
if live_number_int != exact_pr:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": selection_mode,
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"PR #{exact_pr} resolution is ambiguous or mismatched "
|
||||
f"(live number={live_number!r}; fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "pr_ambiguous",
|
||||
}
|
||||
if not (pr_live.get("merged") or pr_live.get("merged_at")):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": selection_mode,
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"PR #{exact_pr} is not merged "
|
||||
"(exact-target cleanup requires a merged PR; "
|
||||
"fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "pr_not_merged",
|
||||
}
|
||||
closed_prs = [pr_live]
|
||||
# Exact mode still needs open heads for remote-delete safety gates.
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
else:
|
||||
# Preserve historical call order (closed then open) for batch callers/tests.
|
||||
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
|
||||
merged_closed: list[dict] = []
|
||||
remote_branch_exists: dict[str, bool] = {}
|
||||
@@ -11634,6 +12094,13 @@ def gitea_reconcile_merged_cleanups(
|
||||
scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
|
||||
_canonical_local_git_root()
|
||||
)
|
||||
# #855: exact-target never inventories or mutates foreign PR scratch trees.
|
||||
if exact_pr is not None:
|
||||
scratch_candidates = [
|
||||
s
|
||||
for s in scratch_candidates
|
||||
if int(s.get("pr_number") or 0) == int(exact_pr)
|
||||
]
|
||||
active_reviewer_leases: dict[int, bool] = {}
|
||||
pr_states: dict[int, dict] = {}
|
||||
for scratch in scratch_candidates:
|
||||
@@ -11668,6 +12135,33 @@ def gitea_reconcile_merged_cleanups(
|
||||
active_reviewer_leases=active_reviewer_leases,
|
||||
pr_states=pr_states,
|
||||
)
|
||||
report["selection_mode"] = selection_mode
|
||||
if exact_pr is not None:
|
||||
report["selected_pr_number"] = exact_pr
|
||||
# Fail closed if exact pin somehow produced other or zero entries.
|
||||
entries = list(report.get("entries") or [])
|
||||
entry_numbers = []
|
||||
for entry in entries:
|
||||
try:
|
||||
entry_numbers.append(int(entry.get("pr_number")))
|
||||
except (TypeError, ValueError):
|
||||
entry_numbers.append(entry.get("pr_number"))
|
||||
if entry_numbers != [exact_pr]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"executed": False,
|
||||
"dry_run": bool(dry_run),
|
||||
"selection_mode": selection_mode,
|
||||
"selected_pr_number": exact_pr,
|
||||
"reasons": [
|
||||
f"exact PR #{exact_pr} selection produced unexpected "
|
||||
f"candidate set {entry_numbers!r} "
|
||||
"(fail closed; no mutation)"
|
||||
],
|
||||
"blocker_kind": "exact_selection_mismatch",
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
report["dry_run"] = True
|
||||
@@ -19006,10 +19500,59 @@ def gitea_update_pr_branch_by_merge(
|
||||
prepared_verdict_head_sha=live_pr_head,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
# #871: the remote head is now advanced; the durable linked-issue lock must
|
||||
# be advanced with it, or a later dead-session recovery can never prove
|
||||
# ownership at the new head. This runs AFTER the successful remote update, so
|
||||
# a failure here is a *partial* lifecycle failure — the remote moved but the
|
||||
# durable state did not — and must never be reported as a full success.
|
||||
claimant = _work_lease_claimant(h)
|
||||
matched_issue = ownership.get("matched_issue")
|
||||
synced_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
lock_refresh: dict = {
|
||||
"refreshed": False,
|
||||
"reasons": ["durable lock head refresh was not attempted"],
|
||||
}
|
||||
if new_head and matched_issue and source_branch and (wt or None):
|
||||
try:
|
||||
lock_refresh = issue_lock_store.apply_durable_lock_head_refresh(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=int(matched_issue),
|
||||
branch_name=source_branch,
|
||||
worktree_path=wt,
|
||||
pr_number=pr_number,
|
||||
identity=claimant.get("username"),
|
||||
profile=claimant.get("profile"),
|
||||
current_pid=os.getpid(),
|
||||
expected_old_head=live_pr_head,
|
||||
new_head=new_head,
|
||||
synced_at=synced_at,
|
||||
base_head=live_base_head,
|
||||
)
|
||||
except Exception as exc:
|
||||
lock_refresh = {
|
||||
"refreshed": False,
|
||||
"reasons": [
|
||||
f"durable lock head refresh raised (fail closed): {_redact(str(exc))}"
|
||||
],
|
||||
}
|
||||
else:
|
||||
lock_refresh = {
|
||||
"refreshed": False,
|
||||
"reasons": [
|
||||
"durable lock head refresh could not run: missing new head, "
|
||||
"linked issue, source branch, or worktree binding"
|
||||
],
|
||||
}
|
||||
|
||||
durable_refreshed = bool(lock_refresh.get("refreshed"))
|
||||
base_result = {
|
||||
"performed": True,
|
||||
"mutation_allowed": True,
|
||||
"durable_lock_refreshed": durable_refreshed,
|
||||
"durable_lock_refresh": lock_refresh,
|
||||
"fully_synchronized": durable_refreshed,
|
||||
"style": "merge",
|
||||
"force_push": False,
|
||||
"rebase": False,
|
||||
@@ -19031,19 +19574,37 @@ def gitea_update_pr_branch_by_merge(
|
||||
"prepared_verdict_invalidated": transition.get(
|
||||
"prepared_verdict_invalidated"
|
||||
),
|
||||
"recommended_next_action": transition.get(
|
||||
"recommended_next_action",
|
||||
pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED,
|
||||
),
|
||||
"transition": transition,
|
||||
"role_kind": role,
|
||||
"profile_name": profile.get("profile_name"),
|
||||
"worktree_path": wt or None,
|
||||
"reasons": list(transition.get("reasons") or []) + [
|
||||
"update-by-merge completed via native Gitea API (style=merge only)"
|
||||
],
|
||||
}
|
||||
|
||||
if durable_refreshed:
|
||||
base_result["success"] = True
|
||||
base_result["recommended_next_action"] = transition.get(
|
||||
"recommended_next_action",
|
||||
pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED,
|
||||
)
|
||||
base_result["reasons"] = list(transition.get("reasons") or []) + [
|
||||
"update-by-merge completed via native Gitea API (style=merge only)",
|
||||
f"durable linked-issue lock #{matched_issue} head refreshed to "
|
||||
f"{new_head} (verified by read-after-write)",
|
||||
]
|
||||
return base_result
|
||||
|
||||
# Partial lifecycle failure: the remote advanced but the durable lock did
|
||||
# not. Do NOT report a fully successful synchronization (#871).
|
||||
base_result["success"] = False
|
||||
base_result["partial_lifecycle_failure"] = True
|
||||
base_result["recommended_next_action"] = pr_sync_status.ACTION_BLOCKED
|
||||
base_result["reasons"] = list(lock_refresh.get("reasons") or []) + [
|
||||
f"PARTIAL LIFECYCLE FAILURE: PR #{pr_number} remote head advanced to "
|
||||
f"{new_head} but the durable linked-issue lock head was not refreshed; "
|
||||
"the synchronization is NOT complete",
|
||||
]
|
||||
return base_result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_conflict_fix_push(
|
||||
|
||||
Reference in New Issue
Block a user