fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)
gitea_bootstrap_author_issue_worktree wrote a lock no downstream author operation accepts, then directed the author straight to implementation. Once the branch carried commits, heartbeat, re-lock, exact-owner renewal, and the #447 create-PR guard all refused simultaneously and no sanctioned recovery path remained eligible. Each of those gates is individually correct. The defect was that two writers disagreed about what a lock is. - Add author_lock_contract as the single canonical definition: claimant, work_lease, lock_provenance, generation, and an explicit expiration state. Both gitea_lock_issue and bootstrap now build through it. - Promote issue_lock_store.lock_claimant to the one shared claimant reader and use it in the ownership check, so a claimant recorded at the lock top level is read rather than refused. The values are still compared against server-resolved identity and profile, so no legacy placement grants anything the canonical placement would not. - Represent missing expiration explicitly. An absent expires_at previously read as "not yet expired", leaving a malformed lock permanently non-expiring and permanently ineligible for #760 renewal. - Bootstrap reads its lock back and verifies it structurally before reporting success. A partial lock fails closed while the worktree is still base-equivalent, names the missing fields, and never reports implementation_allowed. Its exact_next_action now matches the state returned. - Add gitea_recover_incomplete_bootstrap_lock for locks already written by the old bootstrap, including those whose branches carry pushed commits. It never moves, resets, or rewinds a branch, never requires base-equivalence, never pushes or opens a PR, and touches only the target lock. It proves repository, issue, claimant username and profile, branch, worktree, registration, and head before writing, refuses healthy foreign-owned locks, and mints provenance and authorization server-side. - Add gitea_inspect_issue_lock_contract, a strictly read-only surface. - Document the required ordering and the recovery path. The #447 provenance guard is unchanged and the sanctioned source set was not widened: bootstrap now satisfies the guard rather than the guard being relaxed to admit bootstrap. Tests: 61 new cases covering the canonical schema, immediate heartbeat, renewal before and after commits, the create_pr guard, executable next actions, partial and malformed and missing-expiration and expired and same-owner and foreign-owner and legacy locks, recovery isolation, read-only inspection, and the full bootstrap-implement-commit-push-create_pr regression against isolated fixtures. Full suite at head: 28 failed, 5753 passed, 6 skipped, 1042 subtests. Full suite at base82d71b77: 28 failed, 5692 passed, 6 skipped, 1042 subtests. Failing test-ID sets are identical, so there are zero regressions; the +61 passes are this issue's new suite. Issue #949 was preserved and not recovered: its branch remains at92615f474band its worktree, lock, and PR state were not touched. The #949-shaped regression uses isolated fixtures only. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""Target-specific recovery for incomplete bootstrap issue locks (#953).
|
||||
|
||||
The situation this exists for: ``gitea_bootstrap_author_issue_worktree``
|
||||
reported success, wrote an incomplete lock, and told the author to implement.
|
||||
The author did — legitimately, following the tool's own reported next action —
|
||||
and the branch now carries real committed and pushed work. At that point every
|
||||
pre-existing recovery path is simultaneously ineligible:
|
||||
|
||||
* heartbeat refuses, because the claimant is not where it looks;
|
||||
* ``gitea_lock_issue`` refuses, because the branch is no longer base-equivalent;
|
||||
* #760 exact-owner renewal never engages, because a lock with no recorded
|
||||
expiry is never *expired*;
|
||||
* the #447 create-PR guard refuses, because there is no provenance.
|
||||
|
||||
Distinct from every neighbouring path: #753 ``issue_lock_recovery`` requires a
|
||||
dead owner PID, #760 ``issue_lock_renewal`` requires an *expired* lease, and
|
||||
#442 ``issue_lock_adoption`` decides branch adoption. None of them addresses a
|
||||
lock that is structurally incomplete and therefore never expires at all.
|
||||
|
||||
**What this will not do.** It never moves, resets, or rewinds a branch, and
|
||||
never requires base-equivalence — the committed work is the thing being
|
||||
preserved. It never pushes and never opens a pull request. It touches only the
|
||||
one lock file named by (remote, org, repo, issue). It accepts no caller-supplied
|
||||
provenance and no caller-supplied authorization flag; both are minted
|
||||
server-side. It refuses a healthy foreign-owned lock outright, and a matching
|
||||
username alone is never accepted as proof of ownership — the profile must match
|
||||
too, and the lock's recorded binding must agree with the observed branch,
|
||||
worktree, and head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
|
||||
import author_lock_contract
|
||||
import issue_lock_store
|
||||
|
||||
#: Refusal codes, so callers can branch on cause rather than parse prose.
|
||||
REFUSAL_NO_LOCK = "no_durable_lock"
|
||||
REFUSAL_ALREADY_CANONICAL = "already_canonical"
|
||||
REFUSAL_FOREIGN_CLAIMANT = "foreign_claimant"
|
||||
REFUSAL_HEALTHY_FOREIGN = "healthy_foreign_lock"
|
||||
REFUSAL_IDENTITY_UNRESOLVED = "identity_unresolved"
|
||||
REFUSAL_BINDING_MISMATCH = "binding_mismatch"
|
||||
REFUSAL_WORKTREE_INVALID = "worktree_invalid"
|
||||
REFUSAL_HEAD_MISMATCH = "head_mismatch"
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
lhs, rhs = _text(left), _text(right)
|
||||
if not lhs or not rhs:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(lhs) == os.path.realpath(rhs)
|
||||
except OSError:
|
||||
return lhs == rhs
|
||||
|
||||
|
||||
def assess_bootstrap_lock_recovery(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
observed_head: str | None,
|
||||
declared_head: str | None,
|
||||
worktree_exists: bool,
|
||||
worktree_registered: bool,
|
||||
current_branch: str | None,
|
||||
now: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether this exact lock may be upgraded by this exact caller.
|
||||
|
||||
Pure: every input is an observation the caller already made, and nothing
|
||||
here reads or writes the filesystem, git, or Gitea. That is what makes the
|
||||
same decision testable in isolation and reusable by the read-only
|
||||
inspection surface, which must not mutate anything (AC16).
|
||||
|
||||
Returns a dict with ``recovery_sanctioned`` plus the full evidence set. A
|
||||
refusal never raises — it reports, so the caller can surface exactly which
|
||||
piece of evidence was missing.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
refusal_code: str | None = None
|
||||
|
||||
contract = author_lock_contract.assess_lock_contract(existing_lock)
|
||||
|
||||
if not existing_lock:
|
||||
return {
|
||||
"recovery_sanctioned": False,
|
||||
"refusal_code": REFUSAL_NO_LOCK,
|
||||
"reasons": [
|
||||
f"no durable issue lock exists for issue #{issue_number}; there is "
|
||||
"nothing to recover (fail closed)"
|
||||
],
|
||||
"contract": contract,
|
||||
"evidence": {},
|
||||
"expected_generation": None,
|
||||
}
|
||||
|
||||
active_identity = _text(identity)
|
||||
active_profile = _text(profile)
|
||||
recorded = author_lock_contract.lock_claimant(existing_lock)
|
||||
freshness = issue_lock_store.assess_lock_freshness(dict(existing_lock), now=now)
|
||||
generation = issue_lock_store.lock_generation(existing_lock)
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"recorded_claimant": recorded,
|
||||
"active_identity": active_identity,
|
||||
"active_profile": active_profile,
|
||||
"recorded_branch": existing_lock.get("branch_name"),
|
||||
"recorded_worktree": existing_lock.get("worktree_path"),
|
||||
"recorded_owner_session": existing_lock.get("owner_session"),
|
||||
"recorded_generation": generation,
|
||||
"recorded_remote": existing_lock.get("remote"),
|
||||
"recorded_org": existing_lock.get("org"),
|
||||
"recorded_repo": existing_lock.get("repo"),
|
||||
"observed_head": _text(observed_head),
|
||||
"declared_head": _text(declared_head),
|
||||
"current_branch": _text(current_branch),
|
||||
"worktree_exists": bool(worktree_exists),
|
||||
"worktree_registered": bool(worktree_registered),
|
||||
"freshness": freshness,
|
||||
"claimant_placement": contract.get("claimant_placement"),
|
||||
"expiration_state": contract.get("expiration", {}).get("state"),
|
||||
}
|
||||
|
||||
# ── Repository and issue identity (AC10) ──
|
||||
if _text(existing_lock.get("remote")) != _text(remote):
|
||||
reasons.append(
|
||||
f"recorded remote '{existing_lock.get('remote')}' does not match '{remote}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
if _text(existing_lock.get("org")) != _text(org):
|
||||
reasons.append(
|
||||
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
if _text(existing_lock.get("repo")) != _text(repo):
|
||||
reasons.append(
|
||||
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
if existing_lock.get("issue_number") != issue_number:
|
||||
reasons.append(
|
||||
f"lock targets issue #{existing_lock.get('issue_number')}, not "
|
||||
f"#{issue_number}"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
|
||||
# ── Branch and worktree binding (AC10) ──
|
||||
if _text(existing_lock.get("branch_name")) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
if not _same_realpath(existing_lock.get("worktree_path"), worktree_path):
|
||||
reasons.append(
|
||||
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
|
||||
f"match '{worktree_path}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||
|
||||
# ── The worktree is real, registered, and on the branch (AC10) ──
|
||||
# Deliberately no base-equivalence requirement and no constraint on how far
|
||||
# the branch has advanced: the whole point is that it already carries the
|
||||
# author's legitimate commits (AC9).
|
||||
if not worktree_exists:
|
||||
reasons.append(f"declared worktree '{worktree_path}' does not exist")
|
||||
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||
if not worktree_registered:
|
||||
reasons.append(f"worktree '{worktree_path}' is not a registered git worktree")
|
||||
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||
if _text(current_branch) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||
|
||||
# ── Current head fencing (AC10) ──
|
||||
# The caller names the commit it believes it is recovering. A mismatch means
|
||||
# the worktree moved under the caller, so the decision is stale.
|
||||
if not _text(observed_head):
|
||||
reasons.append("could not observe the worktree head")
|
||||
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
|
||||
elif _text(declared_head) and _text(declared_head) != _text(observed_head):
|
||||
reasons.append(
|
||||
f"declared head '{_text(declared_head)}' does not match observed head "
|
||||
f"'{_text(observed_head)}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
|
||||
|
||||
# ── Ownership (AC10, AC11) ──
|
||||
# A matching username alone is never sufficient: the profile must match too,
|
||||
# and both are compared against server-resolved values the caller cannot set.
|
||||
if not active_identity or not active_profile:
|
||||
reasons.append(
|
||||
"active identity and profile could not both be resolved; ownership "
|
||||
"cannot be proven"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_IDENTITY_UNRESOLVED
|
||||
if not recorded["username"] or not recorded["profile"]:
|
||||
reasons.append(
|
||||
"durable lock does not record both a claimant username and profile"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
|
||||
elif (
|
||||
recorded["username"] != active_identity
|
||||
or recorded["profile"] != active_profile
|
||||
):
|
||||
# AC11: a foreign-owned lock is never recoverable through this path,
|
||||
# healthy or not. The healthy case is reported distinctly so the refusal
|
||||
# is legible, but both refuse.
|
||||
if freshness.get("live"):
|
||||
reasons.append(
|
||||
f"lock is owned by a healthy foreign claimant "
|
||||
f"'{recorded['username']}/{recorded['profile']}'; takeover is not "
|
||||
"a recovery path"
|
||||
)
|
||||
refusal_code = REFUSAL_HEALTHY_FOREIGN
|
||||
else:
|
||||
reasons.append(
|
||||
f"lock claimant '{recorded['username']}/{recorded['profile']}' "
|
||||
f"does not match active '{active_identity}/{active_profile}'"
|
||||
)
|
||||
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
|
||||
|
||||
# ── Nothing to recover ──
|
||||
# A lock that is already canonical is left strictly alone. Rewriting it would
|
||||
# mint a new task-session identifier and invalidate the heartbeat token the
|
||||
# legitimate owner is already using.
|
||||
if contract.get("canonical") and not reasons:
|
||||
return {
|
||||
"recovery_sanctioned": False,
|
||||
"refusal_code": REFUSAL_ALREADY_CANONICAL,
|
||||
"reasons": [
|
||||
"lock already satisfies the canonical contract; no recovery is "
|
||||
"required"
|
||||
],
|
||||
"contract": contract,
|
||||
"evidence": evidence,
|
||||
"expected_generation": generation,
|
||||
}
|
||||
|
||||
sanctioned = not reasons
|
||||
return {
|
||||
"recovery_sanctioned": sanctioned,
|
||||
"refusal_code": None if sanctioned else refusal_code,
|
||||
"reasons": reasons,
|
||||
"contract": contract,
|
||||
"evidence": evidence,
|
||||
"expected_generation": generation,
|
||||
}
|
||||
|
||||
|
||||
def build_recovery_record(
|
||||
assessment: Mapping[str, Any],
|
||||
*,
|
||||
recovered_at: str,
|
||||
new_task_session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Auditable record of the ownership and generation transition (AC10).
|
||||
|
||||
A recovered lock must never read as an original claim, so both sides of the
|
||||
transition are preserved: what the incomplete lock recorded, and what
|
||||
replaced it.
|
||||
"""
|
||||
evidence = dict(assessment.get("evidence") or {})
|
||||
contract = dict(assessment.get("contract") or {})
|
||||
return {
|
||||
"recovery_kind": "incomplete_bootstrap_lock",
|
||||
"recovered_at": recovered_at,
|
||||
"prior_contract": contract.get("contract"),
|
||||
"prior_missing_fields": list(contract.get("missing_fields") or []),
|
||||
"prior_claimant_placement": evidence.get("claimant_placement"),
|
||||
"prior_expiration_state": evidence.get("expiration_state"),
|
||||
"prior_generation": evidence.get("recorded_generation"),
|
||||
"prior_owner_session": evidence.get("recorded_owner_session"),
|
||||
"prior_freshness": (evidence.get("freshness") or {}).get("status"),
|
||||
"replacement_task_session_id": new_task_session_id,
|
||||
"preserved_head": evidence.get("observed_head"),
|
||||
"branch_reset": False,
|
||||
"base_equivalence_required": False,
|
||||
}
|
||||
|
||||
|
||||
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["unknown bootstrap lock recovery refusal"]
|
||||
)
|
||||
code = assessment.get("refusal_code") or "refused"
|
||||
return f"Bootstrap lock recovery refused ({code}): {reasons} (fail closed)"
|
||||
Reference in New Issue
Block a user