fix(author): derive AC7 guidance from the state compensation actually leaves (#953)
Review 632 F2. The bootstrap AC7 read-back refusal calls `run_compensating_recovery` and *then* reported `author_lock_contract.recommended_action(contract)` — advice computed from the malformed lock that provoked the rollback, not from the state the rollback left. Compensation releases the lock, removes the worktree (always clean there, no implementation bytes having been written), and deletes the branch, so an author following that advice got `no_durable_lock` from `gitea_recover_incomplete_bootstrap_lock` and, had the lock survived, `worktree_invalid` instead; the `gitea_lock_issue` half of the same sentence cannot bind a worktree that no longer exists. Two refusals in a row for a state a plain bootstrap retry fixes — the unexecutable-guidance failure class this issue exists to remove, reintroduced on the new fail-closed path. Investigating that path surfaced why the "clean retry" state was in practice unreachable: `run_compensating_recovery` has called `issue_lock_store.release_session_lock` since #850, and that function has never existed. The `AttributeError` landed in a bare `except Exception: pass`, so every rollback removed the branch and worktree and silently left the lock behind — precisely the uninspectable, unrecoverable state #953 is about (`gitea_recover_incomplete_bootstrap_lock` refuses `worktree_invalid`, `gitea_lock_issue` has no worktree to bind). Confirmed dead at the pinned base `82d71b77`, not introduced by this branch. `release_session_lock` is therefore implemented: it removes exactly one durable lock whose recorded `owner_session` matches the caller's, keyed by repository when known, refusing on zero or multiple matches so no caller can delete a lock it does not own and an ambiguous directory is never guessed at. Bootstrap phase journals and session pointers that share the directory are excluded by shape. The flock sidecar is deliberately left alone. The caller no longer swallows a release failure; it records `lock_release_failed:...`. `assess_post_compensation_state` then classifies from directly observed durable state — lock file, worktree directory, and branch ref — rather than from the journal's `rolled_back` list, which records only what compensation attempted. Three distinct states: `complete` (nothing remains), `partial` (rollback ran, artifacts survive by design or because a step errored), `failed` (rollback never completed, so nothing is proven removed). `post_compensation_action` answers for exactly what survives: complete -> re-run gitea_bootstrap_author_issue_worktree lock + branch + worktree -> gitea_recover_incomplete_bootstrap_lock branch + worktree, no lock -> gitea_lock_issue (still base-equivalent) lock only, worktree gone -> gitea_inspect_issue_lock_contract branch only -> gitea_inspect_issue_lock_contract, then retry rollback did not complete -> gitea_inspect_issue_lock_contract No branch names an artifact the classification says is gone, and a failed rollback step is stated rather than presented as an intentional outcome. The refusal payload carries `compensating_recovery` and `post_compensation_state` alongside the derived `exact_next_action`, still with `implementation_allowed: false`. Also removes the dead `SOURCE_BOOTSTRAP_LOCK_RECOVERY` constant (review 632 F3), which had no readers and implied a second lock source; the deliberate reuse of `SOURCE_LOCK_ISSUE` is now stated as a comment. The #447 guard and `SANCTIONED_LOCK_SOURCES` remain untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -640,6 +640,99 @@ def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def release_session_lock(
|
||||
*,
|
||||
issue_number: int,
|
||||
session: str,
|
||||
lock_dir: str | None = None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> str:
|
||||
"""Remove exactly the durable lock *session* created for *issue_number*.
|
||||
|
||||
``author_issue_bootstrap.run_compensating_recovery`` has called this name
|
||||
since #850, but it was never defined: the call raised ``AttributeError``
|
||||
into a bare ``except Exception: pass``, so the lock half of every
|
||||
compensating rollback silently did nothing. The branch and worktree were
|
||||
removed and the lock was left behind — a state no sanctioned tool can act
|
||||
on, since recovery refuses ``worktree_invalid`` and ``gitea_lock_issue`` has
|
||||
no worktree to bind (#953 review 632 F2).
|
||||
|
||||
Ownership is proven, not asserted. A record is removed only when its
|
||||
recorded ``owner_session`` equals *session* and its issue number matches;
|
||||
``remote``/``org``/``repo`` narrow it further when supplied. Zero matches or
|
||||
more than one both raise, so a caller can never delete a lock it does not
|
||||
own and an ambiguous directory is never guessed at. The ``.json.lock`` flock
|
||||
sidecar is deliberately left in place — it is a zero-byte mutex another
|
||||
process may hold, and removing it under contention would be a race.
|
||||
|
||||
Returns the removed lock file path.
|
||||
"""
|
||||
target_issue = int(issue_number)
|
||||
owner = str(session or "").strip()
|
||||
if not owner:
|
||||
raise ValueError(
|
||||
"release_session_lock requires the owning session id (fail closed)"
|
||||
)
|
||||
|
||||
def _is_owned_durable_lock(record: dict[str, Any] | None) -> bool:
|
||||
# A durable lock, not a bootstrap phase journal or a session pointer,
|
||||
# both of which can share a directory and carry the same issue number
|
||||
# and owner_session.
|
||||
if not record or "lock_generation" not in record:
|
||||
return False
|
||||
if not str(record.get("branch_name") or "").strip():
|
||||
return False
|
||||
if not str(record.get("worktree_path") or "").strip():
|
||||
return False
|
||||
try:
|
||||
if int(record.get("issue_number") or 0) != target_issue:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return str(record.get("owner_session") or "").strip() == owner
|
||||
|
||||
# Prefer the exact keyed path when the caller knows the repository; scanning
|
||||
# is the fallback for callers that only carry the issue number.
|
||||
if remote and org and repo:
|
||||
exact = lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=target_issue,
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
if not _is_owned_durable_lock(read_lock_file(exact)):
|
||||
raise FileNotFoundError(
|
||||
f"durable issue lock '{exact}' is absent or is not owned by "
|
||||
f"session '{owner}' (fail closed; nothing released)"
|
||||
)
|
||||
os.remove(exact)
|
||||
return exact
|
||||
|
||||
matches: list[str] = []
|
||||
for path in iter_lock_files(lock_dir):
|
||||
if _is_owned_durable_lock(read_lock_file(path)):
|
||||
matches.append(path)
|
||||
|
||||
if not matches:
|
||||
raise FileNotFoundError(
|
||||
f"no durable issue lock for issue #{target_issue} is owned by "
|
||||
f"session '{owner}' (fail closed; nothing released)"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise RuntimeError(
|
||||
f"{len(matches)} durable locks for issue #{target_issue} claim "
|
||||
f"session '{owner}'; refusing to guess which to release "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
path = matches[0]
|
||||
os.remove(path)
|
||||
return path
|
||||
|
||||
|
||||
def find_lock_for_branch(
|
||||
*,
|
||||
remote: str,
|
||||
|
||||
Reference in New Issue
Block a user