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:
+362
-27
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user