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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user