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]>
305 lines
13 KiB
Python
305 lines
13 KiB
Python
"""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)"
|