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 base 82d71b77: 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 at
92615f474b and 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:
2026-07-28 00:35:12 -04:00
co-authored by Claude Opus 4.8
parent 82d71b7702
commit cdf0daefa9
8 changed files with 2390 additions and 58 deletions
+362 -27
View File
@@ -2110,6 +2110,8 @@ import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402
import issue_lock_recovery # noqa: E402
import issue_lock_renewal # noqa: E402
import author_lock_contract # noqa: E402
import bootstrap_lock_recovery # noqa: E402
import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery
import dirty_same_claimant_session_rebind # noqa: E402 # #864
import stacked_pr_support # noqa: E402
@@ -2658,33 +2660,17 @@ def _build_author_issue_work_lease(
worktree_path: str,
host: str | None,
) -> dict:
created = _work_lease_now()
# #790 Slice A: the window comes from the central policy, not a literal here.
# It is also now a *sliding* window — the lease lives ``initial_ttl_minutes``
# past its last valid heartbeat rather than a fixed four hours past its
# creation, so an abandoned task stops holding the claim within one TTL.
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
expires = created + timedelta(minutes=policy.initial_ttl_minutes)
return {
"operation_type": AUTHOR_ISSUE_WORK_LEASE,
"issue_number": issue_number,
"pr_number": None,
"branch": branch_name,
"worktree_path": worktree_path,
"claimant": _work_lease_claimant(host),
"created_at": _work_lease_timestamp(created),
"expires_at": _work_lease_timestamp(expires),
"last_heartbeat_at": _work_lease_timestamp(created),
# #790 AC-N1: the ownership key for this task. Distinct from the recorded
# PID, which is the shared daemon and identifies no individual task.
"task_session_id": issue_lock_store.mint_task_session_id(
AUTHOR_ISSUE_WORK_LEASE
),
# #790 AC-N8: the explicit lifecycle marker. Its absence — never a
# timestamp comparison — is what makes a lock legacy.
"lifecycle_version": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"heartbeat_count": 1,
}
# #953: the lease shape now lives in author_lock_contract so that bootstrap
# and gitea_lock_issue cannot drift apart again. The policy-derived sliding
# TTL (#790 Slice A) and the task-session ownership key (#790 AC-N1) are
# unchanged — they simply have one definition instead of two.
return author_lock_contract.build_author_issue_work_lease(
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
claimant=_work_lease_claimant(host),
created=_work_lease_now(),
)
def _active_work_lease_block(
@@ -4954,6 +4940,355 @@ def gitea_heartbeat_issue_lock(
return outcome
def _observe_recovery_worktree(worktree_path: str) -> dict:
"""Observe head, branch, existence, and registration for lock recovery.
Read-only: it runs ``git`` queries and touches nothing. Kept separate from
the decision so the decision stays a pure function of observations (#953).
"""
observation = {
"worktree_exists": os.path.isdir(worktree_path),
"worktree_registered": False,
"current_branch": "",
"observed_head": "",
}
if not observation["worktree_exists"]:
return observation
try:
observation["current_branch"] = subprocess.run(
["git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
observation["observed_head"] = subprocess.run(
["git", "-C", worktree_path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
listing = subprocess.run(
["git", "-C", worktree_path, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
).stdout
real = os.path.realpath(worktree_path)
observation["worktree_registered"] = any(
os.path.realpath(line.split(" ", 1)[1].strip()) == real
for line in listing.splitlines()
if line.startswith("worktree ")
)
except Exception: # fail closed: unobservable is not provable
return observation
return observation
@mcp.tool()
def gitea_inspect_issue_lock_contract(
issue_number: int,
branch_name: str | None = None,
worktree_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Inspect a durable author issue lock against the canonical contract (#953 AC8/AC16).
Strictly read-only. It performs no lock, lease, branch, worktree, issue, or
pull-request mutation of any kind it reads the durable lock record and
reports. Use it to find out *why* a lock is being refused before choosing a
recovery path, and to confirm afterwards that recovery produced a canonical
lock.
Reports which canonical fields are missing, where the claimant is recorded
(``work_lease`` is canonical, top level is the legacy/bootstrap placement),
whether an expiration is actually recorded as opposed to absent, which
used to masquerade as "not yet expired" whether the lock can be
heartbeated, and whether it satisfies the untouched #447 create-PR
provenance guard.
Args:
issue_number: The issue whose lock to inspect.
branch_name: Optional; when given, the recovery eligibility preview is
evaluated against this branch.
worktree_path: Optional; when given, the recovery eligibility preview is
evaluated against this worktree.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success', 'lock_present', 'lock_contract' (the structural
verdict), 'recommended_action', and when branch_name and
worktree_path are supplied a non-mutating 'recovery_preview'.
"""
blocked = _profile_permission_block(
"gitea.read",
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
h, o, r = _resolve(remote, host, org, repo)
existing = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
contract = author_lock_contract.assess_lock_contract(existing)
result = {
"success": True,
"performed": False,
"mutation_performed": False,
"read_only": True,
"issue_number": issue_number,
"lock_present": bool(existing),
"lock_contract": contract,
"lock_freshness": (
issue_lock_store.assess_lock_freshness(dict(existing))
if existing
else {"status": issue_lock_store.STATUS_ABSENT, "live": False}
),
"recommended_action": author_lock_contract.recommended_action(contract),
}
if branch_name and worktree_path:
resolved = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
observation = _observe_recovery_worktree(resolved)
claimant = _work_lease_claimant(h)
result["recovery_preview"] = bootstrap_lock_recovery.assess_bootstrap_lock_recovery(
existing,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
observed_head=observation["observed_head"],
declared_head=None,
worktree_exists=observation["worktree_exists"],
worktree_registered=observation["worktree_registered"],
current_branch=observation["current_branch"],
)
return result
@mcp.tool()
def gitea_recover_incomplete_bootstrap_lock(
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
dry_run: bool = False,
) -> dict:
"""Upgrade an incomplete bootstrap issue lock to the canonical contract (#953 AC8-AC11).
Explicit, target-specific recovery. It does **not** widen
``gitea_lock_issue``, and it is not a takeover path.
The state it repairs: ``gitea_bootstrap_author_issue_worktree`` reported
success but wrote a lock with the claimant at the top level, no
``work_lease``, no ``lock_provenance``, and no expiry. The author then
implemented, committed, and pushed following bootstrap's own reported next
action after which heartbeat, re-lock, exact-owner renewal, and the #447
create-PR guard all refuse simultaneously.
Deliberate non-behaviours: the branch is never moved, reset, or rewound, and
base-equivalence is never required preserving the already-committed and
pushed work is the entire point. Nothing is pushed and no pull request is
created. Only the single lock file for this exact (remote, org, repo, issue)
is written.
Ownership is proven, never asserted. The claimant recorded on the durable
lock must match **both** the server-resolved identity and the active
profile; a matching username alone is refused. Repository, issue, branch,
worktree, registration, current branch, and head are all verified before any
write, and the declared ``expected_head`` must equal the observed head. A
healthy foreign-owned lock is refused outright. Provenance and authorization
are minted server-side there is no parameter through which a caller can
supply either.
Args:
issue_number: The issue whose incomplete lock is being recovered.
branch_name: The branch recorded on the lock; must match.
worktree_path: The registered worktree recorded on the lock; must match.
expected_head: Full SHA the caller believes the worktree is at. A
mismatch fails closed, so a worktree that moved underneath the
caller cannot be recovered against stale evidence.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
dry_run: Report the decision and evidence, mutate nothing.
Returns:
dict with 'success', 'performed', the resulting canonical
'lock_contract' and 'work_lease', the auditable
'bootstrap_lock_recovery' transition record, and 'exact_next_action'; on
refusal 'success'/'performed' False with 'refusal_code' and 'reasons'
naming exactly which evidence was missing, and no write performed.
"""
task = "recover_incomplete_bootstrap_lock"
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
task
)
if not ok:
return _author_mutation_block(block_reasons)
blocked = _profile_permission_block(
task_capability_map.required_permission(task),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
h, o, r = _resolve(remote, host, org, repo)
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
existing = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
observation = _observe_recovery_worktree(resolved_worktree)
# The claimant pair is resolved server-side from the live session; the
# caller cannot influence which identity or profile recovery compares
# against.
claimant = _work_lease_claimant(h)
assessment = bootstrap_lock_recovery.assess_bootstrap_lock_recovery(
existing,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
observed_head=observation["observed_head"],
declared_head=expected_head,
worktree_exists=observation["worktree_exists"],
worktree_registered=observation["worktree_registered"],
current_branch=observation["current_branch"],
)
if not assessment["recovery_sanctioned"]:
return {
"success": False,
"performed": False,
"mutation_performed": False,
"issue_number": issue_number,
"refusal_code": assessment["refusal_code"],
"reasons": assessment["reasons"],
"message": bootstrap_lock_recovery.format_recovery_refusal(assessment),
"lock_contract": assessment["contract"],
"evidence": assessment["evidence"],
}
if dry_run:
return {
"success": True,
"performed": False,
"mutation_performed": False,
"dry_run": True,
"issue_number": issue_number,
"would_recover": True,
"lock_contract": assessment["contract"],
"evidence": assessment["evidence"],
"exact_next_action": (
"Re-run without dry_run=True to upgrade this lock to the "
"canonical contract."
),
}
recovered = author_lock_contract.build_canonical_issue_lock(
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
tool="gitea_recover_incomplete_bootstrap_lock",
source=issue_lock_provenance.SOURCE_LOCK_ISSUE,
owner_session=(existing or {}).get("owner_session"),
expected_base_sha=(existing or {}).get("expected_base_sha"),
)
# AC10: preserve both sides of the transition so a recovered lock never
# reads as an original claim.
recovered["bootstrap_lock_recovery"] = bootstrap_lock_recovery.build_recovery_record(
assessment,
recovered_at=_work_lease_timestamp(_work_lease_now()),
new_task_session_id=recovered["work_lease"]["task_session_id"],
)
try:
lock_path = issue_lock_store.bind_session_lock(
recovered,
expected_generation=assessment["expected_generation"],
recovery_sanctioned=True,
)
except Exception as exc:
return {
"success": False,
"performed": False,
"mutation_performed": False,
"issue_number": issue_number,
"refusal_code": "lock_write_failed",
"reasons": [str(exc)],
"message": f"Recovered lock could not be persisted: {exc} (fail closed)",
}
written = issue_lock_store.read_lock_file(lock_path)
contract = author_lock_contract.assess_lock_contract(written)
return {
"success": True,
"performed": True,
"mutation_performed": True,
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": resolved_worktree,
"lock_file_path": lock_path,
"lock_contract": contract,
"work_lease": (written or {}).get("work_lease"),
"task_session_id": contract["task_session_id"],
"lock_generation": (written or {}).get("lock_generation"),
"prior_generation": assessment["expected_generation"],
"bootstrap_lock_recovery": (written or {}).get("bootstrap_lock_recovery"),
"preserved_head": observation["observed_head"],
"branch_reset": False,
"pushed": False,
"pr_created": False,
"exact_next_action": (
"Lock is canonical. Heartbeat it with the returned task_session_id, "
"then continue the author workflow; publish and create the pull "
"request through the normal sanctioned calls."
),
}
@mcp.tool()
def gitea_recover_dirty_orphaned_issue_worktree(
issue_number: int,