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
+183 -2
View File
@@ -43,8 +43,12 @@ import lease_policy
# safety requirements forbid.
SOURCE_BOOTSTRAP = issue_lock_provenance.SOURCE_LOCK_ISSUE
#: Recovery of an incomplete bootstrap lock (#953 AC8-AC11).
SOURCE_BOOTSTRAP_LOCK_RECOVERY = "gitea_recover_incomplete_bootstrap_lock"
# Recovery of an incomplete bootstrap lock (#953 AC8-AC11) deliberately writes
# through SOURCE_LOCK_ISSUE too, and records its distinctness in
# ``lock_provenance.written_by_tool`` plus the ``bootstrap_lock_recovery``
# transition block instead. There is no distinct recovery *source* constant, for
# the same reason bootstrap has none: minting one would require widening
# SANCTIONED_LOCK_SOURCES, which the #447 safety requirements forbid.
#: Top-level keys every canonical author issue lock must carry.
REQUIRED_LOCK_FIELDS: tuple[str, ...] = (
@@ -440,3 +444,180 @@ def recommended_action(assessment: Mapping[str, Any]) -> str:
"and worktree to upgrade the lock to the canonical contract, or "
"gitea_lock_issue while the worktree is still base-equivalent."
)
# ── Post-compensation recovery guidance (#953 AC5/AC15, review 632 F2) ──
#
# ``recommended_action`` above answers "what can be done about a lock in this
# shape". That is the wrong question on the bootstrap AC7 refusal path, because
# ``run_compensating_recovery`` has already run by the time the answer is
# reported: it releases the lock, removes the worktree when clean — which it
# always is there, no implementation bytes having been written — and deletes the
# created branch. Recommending incomplete-lock recovery for those artifacts
# hands the author two refusals in a row (``no_durable_lock``, then
# ``worktree_invalid``) for a state that a plain bootstrap retry would fix. The
# advice must describe the state that actually *remains*.
#: Compensation removed every artifact this transition created.
CLEANUP_COMPLETE = "complete"
#: Compensation removed some artifacts; others survive and are still actionable.
CLEANUP_PARTIAL = "partial"
#: Compensation itself failed or could not be observed; nothing is provable.
CLEANUP_FAILED = "failed"
def assess_post_compensation_state(
recovery: Mapping[str, Any] | None,
*,
lock_present: bool,
worktree_present: bool,
branch_present: bool,
) -> dict[str, Any]:
"""Classify what survived compensation, from observed durable state.
Pure. The caller observes the filesystem and git; this decides. Observation
is authoritative over the journal's ``rolled_back`` list, which records what
compensation *attempted*: ``run_compensating_recovery`` swallows a failed
lock release and appends nothing, so an absent marker proves nothing either
way. The list is still carried through as corroborating evidence.
The three states are distinct facts, not degrees of the same one:
* ``CLEANUP_COMPLETE`` — compensation ran and nothing it created remains.
* ``CLEANUP_PARTIAL`` — compensation ran and artifacts survive, whether by
design (a worktree dirty at rollback time, a branch carrying commits) or
because a rollback step errored. Either way the surviving set was observed
directly, so it is known and actionable; ``failed_rollback_steps`` records
which cause applies.
* ``CLEANUP_FAILED`` — compensation never ran to completion, so nothing it
would have removed can be assumed removed.
"""
rolled_back = list((recovery or {}).get("rolled_back") or [])
executed = bool((recovery or {}).get("executed"))
failed_steps = [entry for entry in rolled_back if "_failed" in entry]
surviving: list[str] = []
if lock_present:
surviving.append("lock")
if worktree_present:
surviving.append("worktree")
if branch_present:
surviving.append("branch")
if not executed:
state = CLEANUP_FAILED
elif surviving:
state = CLEANUP_PARTIAL
else:
state = CLEANUP_COMPLETE
return {
"cleanup_state": state,
"compensation_executed": executed,
"lock_present": bool(lock_present),
"worktree_present": bool(worktree_present),
"branch_present": bool(branch_present),
"surviving_artifacts": surviving,
"removed_artifacts": [
name
for name, present in (
("lock", lock_present),
("worktree", worktree_present),
("branch", branch_present),
)
if not present
],
"failed_rollback_steps": failed_steps,
"rolled_back": rolled_back,
}
def post_compensation_action(
state: Mapping[str, Any],
*,
issue_number: int,
branch_name: str,
worktree_path: str,
missing_fields: list[str] | None = None,
) -> str:
"""The one executable next step for the state compensation actually left.
Every branch names only artifacts the classification says still exist, so no
recommendation can point at something the rollback deleted.
"""
missing = ", ".join(missing_fields or []) or "the reported missing fields"
cleanup_state = state.get("cleanup_state")
lock_present = bool(state.get("lock_present"))
worktree_present = bool(state.get("worktree_present"))
branch_present = bool(state.get("branch_present"))
if not state.get("compensation_executed"):
# Compensation never ran, so nothing was rolled back and nothing about
# the remaining state was decided. The read-only surface is the only
# action executable under any state.
return (
"Compensating rollback did not complete, so the remaining state is "
f"not proven. Call gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to establish what survives before any "
"further action. Do not retry bootstrap until it is known."
)
prefix = ""
failed_steps = state.get("failed_rollback_steps") or []
if failed_steps:
prefix = (
"Compensating rollback reported a failed step "
f"({', '.join(failed_steps)}); what survives was observed directly "
"and the action below is scoped to exactly that. "
)
if cleanup_state == CLEANUP_COMPLETE:
return (
"Compensating rollback removed the malformed lock, the branch, and "
f"the worktree, so nothing from this attempt remains. Resolve "
f"{missing} and re-run gitea_bootstrap_author_issue_worktree for "
f"issue #{issue_number} from the clean pre-bootstrap state. Do not "
"call gitea_recover_incomplete_bootstrap_lock: there is no lock, "
"branch, or worktree left for it to act on."
)
if lock_present and worktree_present and branch_present:
return prefix + (
"The lock, branch, and worktree all survive. Call "
"gitea_recover_incomplete_bootstrap_lock for issue "
f"#{issue_number}, branch '{branch_name}', and worktree "
f"'{worktree_path}', passing the worktree's current head as "
"expected_head, to upgrade the lock to the canonical contract."
)
if not lock_present and worktree_present and branch_present:
return prefix + (
"The malformed lock was released but the branch and worktree "
"survive. No implementation bytes were written, so the worktree is "
f"still base-equivalent: call gitea_lock_issue for issue "
f"#{issue_number} on branch '{branch_name}' from worktree "
f"'{worktree_path}' to acquire a canonical lock."
)
if lock_present and not worktree_present:
return prefix + (
f"The worktree for issue #{issue_number} is gone but the durable "
"lock survived, so neither gitea_recover_incomplete_bootstrap_lock "
"(it would refuse worktree_invalid) nor gitea_lock_issue (it has no "
"worktree to bind) is executable. Call "
"gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to confirm the surviving lock; it "
"must be released by its recorded owner before bootstrap is "
"retried."
)
# Lock gone, worktree gone, some git artifact left (a branch with commits,
# or a branch this transition did not create).
return prefix + (
"Compensating rollback removed the lock and worktree; branch "
f"'{branch_name}' survives and was not deleted. Call "
f"gitea_inspect_issue_lock_contract for issue #{issue_number} "
"(read-only) to confirm no durable lock remains, then re-run "
"gitea_bootstrap_author_issue_worktree, which will adopt the existing "
"branch rather than recreating it."
)