fix(mcp): recover clean unpublished author work after the owning session exits
Closes #772 Recovery now dispatches on observed publication state: published_owning_pr (remote branch exists; head equality #753 or strict descendancy #768, unchanged) and unpublished_claim (no remote branch, no PR; ownership proven by the durable lock record plus a local HEAD strictly descending from the server-observed base the branch was cut from). The absence of a remote head is never itself permission. Recovery writes are compare-and-swap against a lock generation, and the mutating lock path and read-only diagnostic assessor share one evaluator. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+45
-2
@@ -148,8 +148,37 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
def lock_generation(lock: dict[str, Any] | None) -> int:
|
||||
"""Monotonic write counter for a durable lock record (#772 AC5).
|
||||
|
||||
Absent or unusable values read as ``0`` so a lock written before generations
|
||||
existed still participates in compare-and-swap: its first recovery expects
|
||||
``0`` and writes ``1``.
|
||||
"""
|
||||
if not isinstance(lock, dict):
|
||||
return 0
|
||||
try:
|
||||
return int(lock.get("lock_generation") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bind_session_lock(
|
||||
lock_data: dict[str, Any],
|
||||
lock_dir: str | None = None,
|
||||
*,
|
||||
expected_generation: int | None = None,
|
||||
) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session.
|
||||
|
||||
``expected_generation`` turns the write into a compare-and-swap (#772 AC5).
|
||||
Recovery decides it may take over a claim by reading the durable lock, but
|
||||
that read and this write are separate steps; without a CAS two replacement
|
||||
sessions can both observe the same dead owner, both pass assessment, and
|
||||
both write — the second silently clobbering the first. Passing the
|
||||
generation observed at assessment time makes exactly one of them win: the
|
||||
loser's expectation no longer matches and it fails closed.
|
||||
"""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
@@ -194,6 +223,20 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
# #772 AC5: compare-and-swap inside the same critical section that
|
||||
# already serializes writers, so the check and the write cannot be
|
||||
# separated by another session's successful recovery.
|
||||
current_generation = lock_generation(existing)
|
||||
if (
|
||||
expected_generation is not None
|
||||
and current_generation != expected_generation
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock generation changed: expected "
|
||||
f"{expected_generation}, found {current_generation}; another "
|
||||
"session already recovered or replaced this claim (fail closed)"
|
||||
)
|
||||
record["lock_generation"] = current_generation + 1
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
|
||||
Reference in New Issue
Block a user