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:
+35
-9
@@ -299,9 +299,13 @@ def _ownership_refusals(
|
||||
f"lock worktree '{lock.get('worktree_path')}' does not match "
|
||||
f"'{worktree_path}'"
|
||||
)
|
||||
lease = lock.get("work_lease") if isinstance(lock, dict) else None
|
||||
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
||||
claimant = claimant if isinstance(claimant, dict) else {}
|
||||
# #953 AC2/AC13/AC14: read through the shared claimant reader so a lock
|
||||
# written by bootstrap — which records the claimant at the top level — is
|
||||
# not refused for "not recording a claimant" when it plainly records one.
|
||||
# This is not a widening: the values are still compared against the
|
||||
# server-resolved identity and profile immediately below, so a legacy
|
||||
# placement grants nothing that the canonical placement would not.
|
||||
claimant = lock_claimant(lock) if isinstance(lock, dict) else {}
|
||||
recorded_identity = str(claimant.get("username") or "").strip()
|
||||
recorded_profile = str(claimant.get("profile") or "").strip()
|
||||
if not recorded_identity or not recorded_profile:
|
||||
@@ -1112,21 +1116,43 @@ def assess_same_issue_lease_conflict(
|
||||
)
|
||||
|
||||
|
||||
def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
|
||||
def lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""Read the claimant from either canonical or legacy placement (#953 AC13/AC14).
|
||||
|
||||
``work_lease.claimant`` is the canonical placement and is preferred; a
|
||||
top-level ``claimant`` is the legacy/bootstrap placement and is accepted as
|
||||
a fallback. This is the single definition. Before #953 the readers
|
||||
disagreed: this module, ``issue_lock_renewal``, and ``issue_lock_recovery``
|
||||
tolerated both placements, while ``_ownership_refusals`` looked only in
|
||||
``work_lease`` — which is what made a bootstrap-written lock
|
||||
un-heartbeatable.
|
||||
|
||||
Preferring ``work_lease`` over the top level is deliberate: once a legacy
|
||||
lock is upgraded, the canonical placement is authoritative and a stale
|
||||
top-level copy must never win.
|
||||
|
||||
This decides *where to look*, never whether ownership is proven — every
|
||||
caller still compares these values against server-resolved identity and
|
||||
profile.
|
||||
"""
|
||||
if not isinstance(lock, dict):
|
||||
return {}
|
||||
claimant = lock.get("claimant")
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
||||
if not isinstance(claimant, dict):
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
||||
claimant = lock.get("claimant")
|
||||
if not isinstance(claimant, dict):
|
||||
return {}
|
||||
return {
|
||||
"username": str(claimant.get("username") or ""),
|
||||
"profile": str(claimant.get("profile") or ""),
|
||||
"username": str(claimant.get("username") or "").strip(),
|
||||
"profile": str(claimant.get("profile") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
#: Back-compatible alias for the pre-#953 private name.
|
||||
_lock_claimant = lock_claimant
|
||||
|
||||
|
||||
def assess_foreign_lock_overwrite(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
incoming_lock: dict[str, Any],
|
||||
|
||||
Reference in New Issue
Block a user