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
+68 -22
View File
@@ -23,6 +23,7 @@ import shutil
import subprocess
from typing import Any, Mapping
import author_lock_contract
import author_mutation_worktree
import control_plane_db
import issue_lock_store
@@ -1198,26 +1199,31 @@ def bootstrap_author_issue_worktree(
save_phase_journal(journal, journal_dir=lock_dir)
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
#
# #953: this used to hand-build a thinner record — claimant at the top
# level, no work_lease, no lock_provenance, no expiry — which every
# downstream reader then refused. It now builds through the one shared
# canonical contract, so the lock bootstrap writes is the same lock
# gitea_lock_issue writes.
from datetime import datetime, timezone
try:
lock_data = {
"remote": remote,
"org": org or "Scaled-Tech-Consulting",
"repo": repo or "Gitea-Tools",
"issue_number": issue_number,
"branch": target_branch,
"branch_name": target_branch,
"worktree_path": target_worktree,
"owner_session": session,
"claimant": {
"username": identity,
"profile": profile,
},
"assignment_id": assignment_id,
"lease_id": lease_id,
"expected_base_sha": live_master_sha,
"created_at": datetime.now(timezone.utc).isoformat(),
}
lock_data = author_lock_contract.build_canonical_issue_lock(
issue_number=issue_number,
branch_name=target_branch,
worktree_path=target_worktree,
remote=remote,
org=org or "Scaled-Tech-Consulting",
repo=repo or "Gitea-Tools",
identity=identity,
profile=profile,
tool="gitea_bootstrap_author_issue_worktree",
source=author_lock_contract.SOURCE_BOOTSTRAP,
owner_session=session,
assignment_id=assignment_id,
lease_id=lease_id,
expected_base_sha=live_master_sha,
)
lock_data["created_at"] = datetime.now(timezone.utc).isoformat()
journal.setdefault("pending_creations", {})["lock"] = True
journal["artifacts_created"]["lock_created"] = True
save_phase_journal(journal, journal_dir=lock_dir)
@@ -1235,9 +1241,42 @@ def bootstrap_author_issue_worktree(
"exact_next_action": "Verify lease/assignment state and retry.",
}
# ── #953 AC7: verify the lock that was actually written ──
# Reporting "lock_created: true" and then directing the author to
# implement is what produced the unrecoverable state: by the time any
# reader refused the lock, the branch already carried commits and every
# sanctioned recovery path had become ineligible. The lock is therefore
# read back from disk and structurally verified *before* this function
# can report success, and a partial lock fails closed here — while the
# branch is still base-equivalent and recovery is still cheap.
written_lock = issue_lock_store.read_lock_file(lock_res)
contract = author_lock_contract.assess_lock_contract(written_lock)
if not contract["canonical"]:
journal["failure_reason"] = author_lock_contract.format_contract_refusal(
contract
)
run_compensating_recovery(journal, root, journal_dir=lock_dir)
return {
"success": False,
"reason_code": "incomplete_issue_lock_contract",
"message": author_lock_contract.format_contract_refusal(contract),
"issue_number": issue_number,
"branch_name": target_branch,
"worktree_path": target_worktree,
"lock_state": lock_res,
"lock_contract": contract,
"missing_fields": contract["missing_fields"],
"implementation_allowed": False,
# AC15: never strand a branch or worktree without a structured
# recovery recommendation.
"exact_next_action": author_lock_contract.recommended_action(contract),
"phase_journal": journal,
}
journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
"status": "completed",
"lock": lock_res,
"lock_contract": contract["contract"],
}
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
"status": "completed",
@@ -1261,9 +1300,16 @@ def bootstrap_author_issue_worktree(
"assignment_id": assignment_id,
"idempotency_key": key,
"lock_state": lock_res,
"lock_contract": contract,
# #953 AC6: the canonical ownership token for this claim. Never null
# on a successful bootstrap — it is the fencing token every
# subsequent heartbeat and renewal is checked against.
"task_session_id": contract["task_session_id"],
"implementation_allowed": True,
"phase_journal": journal,
"exact_next_action": (
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') "
"and proceed with author implementation in the bootstrapped worktree."
),
# #953 AC5: executable under the state actually returned. The lock
# has been read back and verified canonical, so proceeding to
# implementation is genuinely the correct next step here — which is
# exactly what the old unconditional wording could not promise.
"exact_next_action": author_lock_contract.recommended_action(contract),
}