feat: first-class control-plane lease lifecycle (Closes #601)
Make active leases queryable workflow state with canonical adopt, release, expire/reclaim, and abandon operations on the #613 control-plane DB substrate. - lease_lifecycle.py: policy, proof gates, safe_next_action vocabulary - control_plane_db: schema v3 provenance columns + list/inspect/adopt/abandon - MCP tools: gitea_*_workflow_lease(s) for list/inspect/adopt/release/expire/abandon/reclaim - issue_lock_store: sanctioned reclaim of expired author locks (dead pid / missing wt) - Tests cover lifecycle, foreign steal refusal, allocator compatibility, merger handoff
This commit is contained in:
@@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
return left == right
|
||||
|
||||
|
||||
|
||||
def assess_expired_lock_reclaim(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether an expired/stale author issue lock may be reclaimed (#601).
|
||||
|
||||
Required proof (any reclaim of non-live lock):
|
||||
* lease not live (expired or dead pid)
|
||||
* owner process dead OR worktree missing
|
||||
* no force-delete of live foreign ownership
|
||||
"""
|
||||
if not existing_lock:
|
||||
return {
|
||||
"reclaim_allowed": True,
|
||||
"reasons": ["no existing lock"],
|
||||
"freshness": assess_lock_freshness(None, now=now),
|
||||
}
|
||||
freshness = assess_lock_freshness(existing_lock, now=now)
|
||||
if freshness.get("live"):
|
||||
return {
|
||||
"reclaim_allowed": False,
|
||||
"reasons": ["lock is still live; cannot reclaim (fail closed)"],
|
||||
"freshness": freshness,
|
||||
}
|
||||
pid = existing_lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = existing_lock.get("pid")
|
||||
dead = not is_process_alive(pid) if pid is not None else True
|
||||
wt = str(existing_lock.get("worktree_path") or "")
|
||||
missing_wt = (not wt) or (not os.path.isdir(os.path.realpath(wt)))
|
||||
if not (dead or missing_wt):
|
||||
return {
|
||||
"reclaim_allowed": False,
|
||||
"reasons": [
|
||||
"expired/stale lock still has live owner pid and present worktree; "
|
||||
"recovery review required (fail closed)"
|
||||
],
|
||||
"freshness": freshness,
|
||||
"owner_pid_dead": dead,
|
||||
"worktree_missing": missing_wt,
|
||||
}
|
||||
return {
|
||||
"reclaim_allowed": True,
|
||||
"reasons": [
|
||||
"non-live lock with dead process and/or missing worktree; "
|
||||
"sanctioned reclaim allowed"
|
||||
],
|
||||
"freshness": freshness,
|
||||
"owner_pid_dead": dead,
|
||||
"worktree_missing": missing_wt,
|
||||
"prior_branch": existing_lock.get("branch_name"),
|
||||
"prior_worktree": existing_lock.get("worktree_path"),
|
||||
"prior_pid": pid,
|
||||
}
|
||||
|
||||
|
||||
def assess_same_issue_lease_conflict(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
@@ -405,6 +463,11 @@ def assess_same_issue_lease_conflict(
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
|
||||
if reclaim.get("reclaim_allowed"):
|
||||
# #601: expired + dead pid / missing worktree may be reclaimed
|
||||
# through the normal lock path (sanctioned overwrite).
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
|
||||
Reference in New Issue
Block a user