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:
2026-07-28 02:08:21 -04:00
co-authored by Claude Opus 4.8
parent 55d66c57e4
commit 1aa351718a
4 changed files with 406 additions and 7 deletions
+69 -5
View File
@@ -279,6 +279,36 @@ def _verify_assignment_and_lease_ids(
return None
def _branch_exists(canonical_repo_root: str, branch_name: str) -> bool:
"""Whether *branch_name* still resolves in the canonical checkout (#953 F2).
Used after compensating recovery to observe what survived rather than infer
it from the journal. Fails closed to ``True``: an unobservable branch is
reported as present, so the recommendation stays conservative rather than
telling an author to re-bootstrap over something that may still be there.
"""
if not branch_name:
return False
try:
res = subprocess.run(
[
"git",
"-C",
canonical_repo_root,
"rev-parse",
"--verify",
"--quiet",
f"refs/heads/{branch_name}",
],
capture_output=True,
text=True,
check=False,
)
except Exception:
return True
return res.returncode == 0
def run_compensating_recovery(
journal: dict[str, Any],
canonical_repo_root: str,
@@ -315,10 +345,21 @@ def run_compensating_recovery(
issue_number=issue_num,
session=session_id,
lock_dir=journal_dir,
remote=journal.get("remote"),
# The same defaults the lock was written under, so the
# rollback targets the exact file bind_session_lock keyed.
org=journal.get("org") or "Scaled-Tech-Consulting",
repo=journal.get("repo") or "Gitea-Tools",
)
rolled_back.append(f"lock:issue-{issue_num}")
except Exception:
pass
except Exception as exc:
# #953 F2: a swallowed failure here is what made the rollback
# report success while leaving an unrecoverable lock behind.
# Record it so the post-compensation classification can see the
# lock survived and recommend accordingly.
rolled_back.append(
f"lock_release_failed:issue-{issue_num}:{type(exc).__name__}"
)
artifacts["lock_created"] = False
worktree_created = (
@@ -1255,7 +1296,21 @@ def bootstrap_author_issue_worktree(
journal["failure_reason"] = author_lock_contract.format_contract_refusal(
contract
)
run_compensating_recovery(journal, root, journal_dir=lock_dir)
compensation = run_compensating_recovery(
journal, root, journal_dir=lock_dir
)
# AC5/AC15: the recommendation must describe the state compensation
# actually left, not the state that provoked it.
# ``run_compensating_recovery`` has by now released the lock, removed
# the worktree, and deleted the branch, so recommending
# incomplete-lock recovery for those exact artifacts would refuse
# twice over. Observe what survived and answer for that.
post_state = author_lock_contract.assess_post_compensation_state(
compensation,
lock_present=bool(lock_res) and os.path.exists(lock_res),
worktree_present=os.path.isdir(target_worktree),
branch_present=_branch_exists(root, target_branch),
)
return {
"success": False,
"reason_code": "incomplete_issue_lock_contract",
@@ -1267,9 +1322,18 @@ def bootstrap_author_issue_worktree(
"lock_contract": contract,
"missing_fields": contract["missing_fields"],
"implementation_allowed": False,
"compensating_recovery": compensation,
"post_compensation_state": post_state,
# AC15: never strand a branch or worktree without a structured
# recovery recommendation.
"exact_next_action": author_lock_contract.recommended_action(contract),
# recovery recommendation — and never name an artifact the
# rollback has already deleted.
"exact_next_action": author_lock_contract.post_compensation_action(
post_state,
issue_number=issue_number,
branch_name=target_branch,
worktree_path=target_worktree,
missing_fields=contract["missing_fields"],
),
"phase_journal": journal,
}