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:
@@ -38,6 +38,44 @@ If bootstrap returns `success: false` with
|
||||
`exact_next_action` names the executable recovery step. Bootstrap's reported
|
||||
next action always matches the state it actually returned.
|
||||
|
||||
### What that refusal leaves behind
|
||||
|
||||
The AC7 refusal runs `run_compensating_recovery` *before* it reports, so the
|
||||
advice has to describe the post-rollback state rather than the shape of the lock
|
||||
that provoked it. Recommending incomplete-lock recovery for artifacts the
|
||||
rollback already deleted would produce `no_durable_lock` and then
|
||||
`worktree_invalid` — two refusals for a state a plain retry fixes.
|
||||
|
||||
The refusal therefore carries `compensating_recovery` and
|
||||
`post_compensation_state`, and derives `exact_next_action` from what was
|
||||
observed on disk. `cleanup_state` is one of:
|
||||
|
||||
| `cleanup_state` | Meaning | Next action |
|
||||
| --- | --- | --- |
|
||||
| `complete` | lock, branch, and worktree all removed | resolve `missing_fields` and re-run `gitea_bootstrap_author_issue_worktree` |
|
||||
| `partial` | rollback ran; some artifacts survive, by design or because a step errored | scoped to exactly what survives — see below |
|
||||
| `failed` | rollback never completed, so nothing is proven removed | `gitea_inspect_issue_lock_contract` (read-only) before anything else |
|
||||
|
||||
Within `partial`, the surviving set decides the action:
|
||||
|
||||
| Survives | Next action |
|
||||
| --- | --- |
|
||||
| lock + branch + worktree | `gitea_recover_incomplete_bootstrap_lock` for that exact issue, branch, and worktree |
|
||||
| branch + worktree (lock released) | `gitea_lock_issue` — no implementation bytes were written, so the worktree is still base-equivalent |
|
||||
| lock only (worktree removed) | `gitea_inspect_issue_lock_contract`; the surviving lock must be released by its recorded owner before bootstrap is retried |
|
||||
| branch only | `gitea_inspect_issue_lock_contract`, then re-run bootstrap, which adopts the existing branch |
|
||||
|
||||
`failed_rollback_steps` names any rollback step that errored, and the returned
|
||||
action says so rather than presenting the surviving state as intentional.
|
||||
|
||||
> The lock half of that rollback was dead code until #953 review 632 F2:
|
||||
> `run_compensating_recovery` called `issue_lock_store.release_session_lock`,
|
||||
> which did not exist, inside a bare `except Exception: pass`. Every rollback
|
||||
> removed the branch and worktree and silently left the lock — the exact
|
||||
> uninspectable, unrecoverable state this issue exists to eliminate. The
|
||||
> function now exists, releases only a lock whose recorded `owner_session`
|
||||
> matches, and its failures are recorded rather than swallowed.
|
||||
|
||||
## The contract
|
||||
|
||||
A canonical lock carries every field in
|
||||
@@ -125,6 +163,29 @@ the transition — prior contract, prior missing fields, prior generation, prior
|
||||
owning session, the replacement `task_session_id`, and the preserved head — so a
|
||||
recovered claim never reads as an original one.
|
||||
|
||||
### Gates, in order
|
||||
|
||||
`gitea_recover_incomplete_bootstrap_lock` is an author-only durable-lock
|
||||
mutation and carries the same three gates as every comparable author operation,
|
||||
in this order:
|
||||
|
||||
1. `role_session_router.check_author_mutation_after_reviewer_stop` — no author
|
||||
fallback after a reviewer `wrong_role_stop`.
|
||||
2. `_namespace_mutation_block(task, remote=remote, author_role_exclusive=True)` —
|
||||
the namespace wall. It refuses a reviewer-bound session and, because this
|
||||
task's required permission is `gitea.issue.comment` (which merger,
|
||||
controller, and reconciler profiles also hold), additionally requires the
|
||||
active profile's derived role kind to be exactly `author`. A refusal carries
|
||||
`namespace_block: true` and emits a `BLOCKED` audit record naming the
|
||||
namespace and profile.
|
||||
3. `_profile_permission_block` — operation, provenance, and session-context
|
||||
gates.
|
||||
|
||||
Exact-owner claimant matching inside `assess_bootstrap_lock_recovery` runs
|
||||
*after* all three. It is a further layer, never a substitute for them: on its
|
||||
own it refuses one step too late and leaves the audit trail silent about the
|
||||
attempt.
|
||||
|
||||
### Refusals
|
||||
|
||||
| `refusal_code` | Meaning |
|
||||
|
||||
Reference in New Issue
Block a user