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:
+68
-22
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user