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:
+26
-3
@@ -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)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user