fix(lease): honor heartbeat-lifecycle non-live reclaim bands in same-issue conflict gate (#790 review #502/#516)

assess_same_issue_lease_conflict entered the reclaim branch only under is_lease_expired, so a heartbeat-lifecycle lease that is non-live but whose expires_at is still in the future (stale_absolute_cap under default policy; stale_missed_heartbeat under TTL>grace) fell through to the foreign active-lease block and never reached assess_expired_lock_reclaim, which already permits those bands. The gate now also enters reclaim/renewal when a heartbeat-lifecycle lease is not is_lease_live even with a future expires_at; legacy locks are excluded and keep their absolute expires_at clock (AC-N8). Adds tests/test_issue_794_conflict_gate_reclaim.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-24 07:38:57 -04:00
co-authored by Claude Opus 4.8
parent 6a636c58e7
commit b6a8989c77
2 changed files with 219 additions and 3 deletions
+26 -3
View File
@@ -1041,7 +1041,28 @@ def assess_same_issue_lease_conflict(
existing_branch == branch_name
and _same_realpath(str(existing_worktree or ""), worktree_path)
)
if is_lease_expired(existing_lock, now=now):
expired = is_lease_expired(existing_lock, now=now)
# #790 review #502/#516: a heartbeat-lifecycle lease that is non-live but
# whose absolute expires_at is still in the future must enter the same
# reclaim/renewal disposition as an expired lease. This happens for
# stale_absolute_cap (a session that keeps heartbeating past the 8h cap has
# expires_at = last_heartbeat + TTL in the future) and for
# stale_missed_heartbeat under an independent TTL>grace policy. Keying this
# gate on is_lease_expired alone left assess_expired_lock_reclaim — which
# already permits exactly those bands — unreachable from the acquisition
# path, so the load-bearing heartbeat was not load-bearing for foreign
# reclaim: the precise abandonment scenario #790 exists to fix.
# assess_foreign_lock_overwrite already keys on is_lease_live; this makes the
# same-issue path consistent with it. Legacy leases keep their absolute-expiry
# clock (AC-N8): is_lease_live already decides a legacy lease from expires_at
# / dead-PID alone and is_legacy_lease excludes it here, so this widening is a
# no-op for every pre-lifecycle lock.
heartbeat_non_live_reclaimable = (
not expired
and not is_legacy_lease(existing_lock)
and not is_lease_live(existing_lock, now=now)
)
if expired or heartbeat_non_live_reclaimable:
# #760 AC1/AC2: exact-owner renewal is a different disposition from
# foreign takeover and is evaluated first. Before this, both branches
# below returned unconditionally, so the same_owner allowance further
@@ -1055,10 +1076,12 @@ def assess_same_issue_lease_conflict(
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).
# through the normal lock path (sanctioned overwrite). #790: the new
# stale heartbeat bands are reclaimable here on the same evidence.
return None
descriptor = "expired" if expired else "non-live"
return (
f"Issue #{issue_number} has an expired {operation_type} lease on "
f"Issue #{issue_number} has an {descriptor} {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
"Recovery review is required before takeover (fail closed)"
)