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 import subprocess
from typing import Any, Mapping from typing import Any, Mapping
import author_lock_contract
import author_mutation_worktree import author_mutation_worktree
import control_plane_db import control_plane_db
import issue_lock_store import issue_lock_store
@@ -1198,26 +1199,31 @@ def bootstrap_author_issue_worktree(
save_phase_journal(journal, journal_dir=lock_dir) save_phase_journal(journal, journal_dir=lock_dir)
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition # 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 from datetime import datetime, timezone
try: try:
lock_data = { lock_data = author_lock_contract.build_canonical_issue_lock(
"remote": remote, issue_number=issue_number,
"org": org or "Scaled-Tech-Consulting", branch_name=target_branch,
"repo": repo or "Gitea-Tools", worktree_path=target_worktree,
"issue_number": issue_number, remote=remote,
"branch": target_branch, org=org or "Scaled-Tech-Consulting",
"branch_name": target_branch, repo=repo or "Gitea-Tools",
"worktree_path": target_worktree, identity=identity,
"owner_session": session, profile=profile,
"claimant": { tool="gitea_bootstrap_author_issue_worktree",
"username": identity, source=author_lock_contract.SOURCE_BOOTSTRAP,
"profile": profile, owner_session=session,
}, assignment_id=assignment_id,
"assignment_id": assignment_id, lease_id=lease_id,
"lease_id": lease_id, expected_base_sha=live_master_sha,
"expected_base_sha": live_master_sha, )
"created_at": datetime.now(timezone.utc).isoformat(), lock_data["created_at"] = datetime.now(timezone.utc).isoformat()
}
journal.setdefault("pending_creations", {})["lock"] = True journal.setdefault("pending_creations", {})["lock"] = True
journal["artifacts_created"]["lock_created"] = True journal["artifacts_created"]["lock_created"] = True
save_phase_journal(journal, journal_dir=lock_dir) 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.", "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] = { journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
"status": "completed", "status": "completed",
"lock": lock_res, "lock": lock_res,
"lock_contract": contract["contract"],
} }
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = { journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
"status": "completed", "status": "completed",
@@ -1261,9 +1300,16 @@ def bootstrap_author_issue_worktree(
"assignment_id": assignment_id, "assignment_id": assignment_id,
"idempotency_key": key, "idempotency_key": key,
"lock_state": lock_res, "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, "phase_journal": journal,
"exact_next_action": ( # #953 AC5: executable under the state actually returned. The lock
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') " # has been read back and verified canonical, so proceeding to
"and proceed with author implementation in the bootstrapped worktree." # 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),
} }
+442
View File
@@ -0,0 +1,442 @@
"""One canonical author issue-lock contract shared by every writer (#953).
Before this module, ``gitea_lock_issue`` and
``gitea_bootstrap_author_issue_worktree`` each wrote their own lock record.
``gitea_lock_issue`` wrote the canonical shape — ``work_lease`` carrying the
claimant plus a sanctioned ``lock_provenance`` — while bootstrap wrote a thinner
record with the claimant at the lock top level, ``lease_id: null``, and no
``work_lease``, ``lock_provenance``, or expiry at all.
Every downstream reader was written against the canonical shape, so a lock that
bootstrap reported as successfully created was simultaneously:
* un-heartbeatable — the ownership check read the claimant only from
``work_lease.claimant``;
* un-renewable — expiry is read only from ``work_lease.expires_at``, so a
missing lease read as "never expires", and #760 exact-owner renewal only ever
assesses an *expired* lease;
* un-re-lockable — the branch had by then advanced past its base;
* and rejected by the #447 create-PR provenance guard.
Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock *is*. This module is the single definition, and both
writers now build through it.
Nothing here weakens a guard. ``build_sanctioned_lock_provenance`` remains the
only provenance source, provenance is never accepted from a caller, and the
#447 guard is untouched — this module simply makes bootstrap satisfy it.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
import issue_lock_provenance
import issue_lock_store
import lease_policy
# Bootstrap writes through the same sanctioned source as gitea_lock_issue: the
# lock it produces *is* a canonical lock, not a second dialect that readers must
# learn. Adding a distinct source would have required widening
# SANCTIONED_LOCK_SOURCES, which is exactly the #447 weakening this issue's
# safety requirements forbid.
SOURCE_BOOTSTRAP = issue_lock_provenance.SOURCE_LOCK_ISSUE
#: Recovery of an incomplete bootstrap lock (#953 AC8-AC11).
SOURCE_BOOTSTRAP_LOCK_RECOVERY = "gitea_recover_incomplete_bootstrap_lock"
#: Top-level keys every canonical author issue lock must carry.
REQUIRED_LOCK_FIELDS: tuple[str, ...] = (
"remote",
"org",
"repo",
"issue_number",
"branch_name",
"worktree_path",
"work_lease",
"lock_provenance",
)
#: Keys every canonical ``work_lease`` must carry.
REQUIRED_WORK_LEASE_FIELDS: tuple[str, ...] = (
"operation_type",
"issue_number",
"branch",
"worktree_path",
"claimant",
"created_at",
"expires_at",
"last_heartbeat_at",
"task_session_id",
"lifecycle_version",
)
# ── Explicit expiration states (AC12) ──
# The bug this replaces: a lock with no recorded expiry produced
# ``is_lease_expired() -> False``, which reads as "not yet expired" and made the
# lock permanently non-expiring *and* permanently ineligible for the renewal
# path, which only ever assesses an expired lease. "Absent" and "in the future"
# are different facts and are now named differently.
EXPIRATION_RECORDED = "recorded"
EXPIRATION_MISSING = "missing"
EXPIRATION_UNPARSEABLE = "unparseable"
#: Structural verdicts returned by :func:`assess_lock_contract`.
CONTRACT_CANONICAL = "canonical"
CONTRACT_INCOMPLETE = "incomplete"
CONTRACT_LEGACY = "legacy"
CONTRACT_ABSENT = "absent"
def _text(value: Any) -> str:
return str(value or "").strip()
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def format_timestamp(value: datetime) -> str:
"""Serialize in the durable ``...Z`` form already used on disk."""
return (
value.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def lock_claimant(lock: Mapping[str, Any] | None) -> dict[str, str]:
"""Read the claimant from either canonical or legacy placement.
``work_lease.claimant`` is canonical and is preferred. A top-level
``claimant`` is the legacy/bootstrap placement and is accepted as a
fallback (AC14) — three separate readers already disagreed about this
(``issue_lock_store``, ``issue_lock_renewal``, ``issue_lock_recovery``),
which is why it now lives in one place.
Reading a legacy placement is *not* a widening: every caller still compares
the values it returns against server-resolved identity and profile. This
only decides where to look, never whether ownership is proven.
Delegates to ``issue_lock_store.lock_claimant`` rather than reimplementing
the rule. A second copy here would be a fourth reader that could drift from
the other three, which is the exact failure #953 exists to end. It lives in
the store because ``author_lock_contract`` imports the store, so defining it
here would make that import circular.
"""
recorded = issue_lock_store.lock_claimant(dict(lock) if isinstance(lock, Mapping) else None)
return {
"username": _text(recorded.get("username")),
"profile": _text(recorded.get("profile")),
}
def claimant_placement(lock: Mapping[str, Any] | None) -> str:
"""Where the claimant was found: ``work_lease``, ``top_level``, or ``absent``."""
if not isinstance(lock, Mapping):
return "absent"
lease = lock.get("work_lease")
if isinstance(lease, Mapping) and isinstance(lease.get("claimant"), Mapping):
return "work_lease"
if isinstance(lock.get("claimant"), Mapping):
return "top_level"
return "absent"
def build_claimant(*, username: str | None, profile: str | None) -> dict[str, str]:
"""Build the canonical claimant pair from server-resolved values."""
return {"username": _text(username), "profile": _text(profile)}
def build_author_issue_work_lease(
*,
issue_number: int,
branch_name: str,
worktree_path: str,
claimant: Mapping[str, Any],
task_session_id: str | None = None,
created: datetime | None = None,
) -> dict[str, Any]:
"""Build the canonical author ``work_lease``.
The single definition behind both writers. The TTL comes from the central
policy rather than a literal, and the window slides from the last valid
heartbeat (#790), so an abandoned task releases its claim within one TTL.
"""
started = created or now_utc()
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
expires = started + timedelta(minutes=policy.initial_ttl_minutes)
session_id = _text(task_session_id) or issue_lock_store.mint_task_session_id(
issue_lock_store.AUTHOR_ISSUE_WORK_LEASE
)
return {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": int(issue_number),
"pr_number": None,
"branch": branch_name,
"worktree_path": worktree_path,
"claimant": dict(claimant),
"created_at": format_timestamp(started),
"expires_at": format_timestamp(expires),
"last_heartbeat_at": format_timestamp(started),
# #790 AC-N1: the ownership key for this task, distinct from the
# recorded PID, which is the shared daemon and identifies no task.
"task_session_id": session_id,
# #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,
}
def build_canonical_issue_lock(
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
tool: str,
source: str = issue_lock_provenance.SOURCE_LOCK_ISSUE,
owner_session: str | None = None,
assignment_id: str | None = None,
lease_id: str | None = None,
expected_base_sha: str | None = None,
task_session_id: str | None = None,
created: datetime | None = None,
) -> dict[str, Any]:
"""Build a complete canonical lock record.
``tool`` and ``source`` are server-supplied. There is deliberately no
parameter through which a caller could inject provenance: the #953 safety
requirements forbid caller-manufactured provenance, so provenance is always
minted here from ``build_sanctioned_lock_provenance``.
"""
claimant = build_claimant(username=identity, profile=profile)
work_lease = build_author_issue_work_lease(
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
claimant=claimant,
task_session_id=task_session_id,
created=created,
)
record: dict[str, Any] = {
"remote": remote,
"org": org,
"repo": repo,
"issue_number": int(issue_number),
"branch": branch_name,
"branch_name": branch_name,
"worktree_path": worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool=tool,
source=source,
claimant=claimant,
),
}
if owner_session is not None:
record["owner_session"] = owner_session
if assignment_id is not None:
record["assignment_id"] = assignment_id
# #953 AC6: a null lease id is recorded only when no workflow lease was
# allocated for this bootstrap. The task-session identifier in the
# work_lease is what downstream ownership checks fence on, and it is never
# null on a canonical lock.
if lease_id is not None:
record["lease_id"] = lease_id
if expected_base_sha is not None:
record["expected_base_sha"] = expected_base_sha
return record
def expiration_state(lock: Mapping[str, Any] | None) -> dict[str, Any]:
"""Classify a lock's recorded expiry explicitly (AC12).
Distinguishes "no expiry was ever recorded" from "an expiry was recorded
and is still in the future". Collapsing those two into a single ``False``
from ``is_lease_expired`` is what let a malformed lock be treated as
permanently live and simultaneously never renewable.
"""
if not isinstance(lock, Mapping):
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
lease = lock.get("work_lease")
raw = lease.get("expires_at") if isinstance(lease, Mapping) else None
text = _text(raw)
if not text:
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(
timezone.utc
)
except ValueError:
return {"state": EXPIRATION_UNPARSEABLE, "expires_at": text, "expired": None}
return {
"state": EXPIRATION_RECORDED,
"expires_at": text,
"expired": parsed <= now_utc(),
}
def missing_contract_fields(lock: Mapping[str, Any] | None) -> list[str]:
"""Name every canonical field a lock does not carry (AC7)."""
if not isinstance(lock, Mapping):
return ["<no lock record>"]
missing: list[str] = []
for field in REQUIRED_LOCK_FIELDS:
value = lock.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(field)
lease = lock.get("work_lease")
if not isinstance(lease, Mapping):
if "work_lease" not in missing:
missing.append("work_lease")
else:
for field in REQUIRED_WORK_LEASE_FIELDS:
value = lease.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(f"work_lease.{field}")
provenance = lock.get("lock_provenance")
if isinstance(provenance, Mapping):
if (
_text(provenance.get("source"))
not in issue_lock_provenance.SANCTIONED_LOCK_SOURCES
):
missing.append("lock_provenance.source (not sanctioned)")
if not _text(provenance.get("written_by_tool")):
missing.append("lock_provenance.written_by_tool")
claimant = lock_claimant(lock)
if not claimant["username"]:
missing.append("claimant.username")
if not claimant["profile"]:
missing.append("claimant.profile")
return missing
def assess_lock_contract(lock: Mapping[str, Any] | None) -> dict[str, Any]:
"""Structural, read-only verdict on a durable lock record (AC7, AC16).
Pure inspection: it reads the record it is handed and mutates nothing —
no lock, lease, branch, worktree, issue, or PR. Callers use it both to
verify a lock they just wrote and to report on one they found.
"""
if not isinstance(lock, Mapping) or not lock:
return {
"contract": CONTRACT_ABSENT,
"canonical": False,
"missing_fields": ["<no lock record>"],
"claimant": {"username": "", "profile": ""},
"claimant_placement": "absent",
"expiration": {
"state": EXPIRATION_MISSING,
"expires_at": None,
"expired": None,
},
"heartbeatable": False,
"create_pr_eligible": False,
"lock_generation": None,
"task_session_id": None,
"reasons": ["no durable lock record"],
}
missing = missing_contract_fields(lock)
claimant = lock_claimant(lock)
placement = claimant_placement(lock)
expiration = expiration_state(lock)
provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(dict(lock))
# Canonical means: every required field present, the claimant in the
# canonical placement, an expiry actually recorded, and the untouched #447
# guard satisfied.
canonical = (
not missing
and placement == "work_lease"
and expiration["state"] == EXPIRATION_RECORDED
and bool(provenance_check.get("proven"))
)
if canonical:
contract = CONTRACT_CANONICAL
elif placement == "top_level" and claimant["username"] and claimant["profile"]:
contract = CONTRACT_LEGACY
else:
contract = CONTRACT_INCOMPLETE
reasons: list[str] = []
if missing:
reasons.append("missing canonical fields: " + ", ".join(missing))
if placement == "top_level":
reasons.append(
"claimant recorded at the lock top level rather than in work_lease "
"(legacy/bootstrap placement)"
)
if expiration["state"] == EXPIRATION_MISSING:
reasons.append(
"no expiration recorded; the lock is neither expirable nor renewable "
"until it is upgraded"
)
elif expiration["state"] == EXPIRATION_UNPARSEABLE:
reasons.append(f"unparseable expires_at '{expiration['expires_at']}'")
if provenance_check.get("block"):
reasons.extend(provenance_check.get("reasons") or [])
# Heartbeat needs the claimant pair (from either placement, post-fix) plus a
# task-session identifier to fence on.
lease = lock.get("work_lease")
task_session_id = (
_text(lease.get("task_session_id")) if isinstance(lease, Mapping) else ""
)
heartbeatable = bool(
claimant["username"] and claimant["profile"] and task_session_id
)
return {
"contract": contract,
"canonical": canonical,
"missing_fields": missing,
"claimant": claimant,
"claimant_placement": placement,
"expiration": expiration,
"heartbeatable": heartbeatable,
"create_pr_eligible": bool(provenance_check.get("proven")),
"lock_generation": lock.get("lock_generation"),
"task_session_id": task_session_id or None,
"reasons": reasons,
}
def format_contract_refusal(assessment: Mapping[str, Any]) -> str:
"""Human-readable refusal naming exactly what the lock is missing."""
missing = ", ".join(assessment.get("missing_fields") or []) or "unknown fields"
return (
"Issue lock contract incomplete (#953): "
f"{missing}. The lock cannot be heartbeated, renewed, or accepted by "
"gitea_create_pr in this state (fail closed)"
)
def recommended_action(assessment: Mapping[str, Any]) -> str:
"""The one executable next step for a lock in this state (AC5, AC15)."""
contract = assessment.get("contract")
if contract == CONTRACT_CANONICAL:
return (
"Lock is canonical. Call gitea_whoami, then "
"gitea_resolve_task_capability(task='work_issue'), then proceed with "
"author implementation in the bootstrapped worktree."
)
if contract == CONTRACT_ABSENT:
return (
"No durable lock exists. Call gitea_lock_issue for this issue and "
"branch before writing any implementation bytes."
)
return (
"Do not begin implementation. Call "
"gitea_recover_incomplete_bootstrap_lock for this exact issue, branch, "
"and worktree to upgrade the lock to the canonical contract, or "
"gitea_lock_issue while the worktree is still base-equivalent."
)
+304
View File
@@ -0,0 +1,304 @@
"""Target-specific recovery for incomplete bootstrap issue locks (#953).
The situation this exists for: ``gitea_bootstrap_author_issue_worktree``
reported success, wrote an incomplete lock, and told the author to implement.
The author did — legitimately, following the tool's own reported next action —
and the branch now carries real committed and pushed work. At that point every
pre-existing recovery path is simultaneously ineligible:
* heartbeat refuses, because the claimant is not where it looks;
* ``gitea_lock_issue`` refuses, because the branch is no longer base-equivalent;
* #760 exact-owner renewal never engages, because a lock with no recorded
expiry is never *expired*;
* the #447 create-PR guard refuses, because there is no provenance.
Distinct from every neighbouring path: #753 ``issue_lock_recovery`` requires a
dead owner PID, #760 ``issue_lock_renewal`` requires an *expired* lease, and
#442 ``issue_lock_adoption`` decides branch adoption. None of them addresses a
lock that is structurally incomplete and therefore never expires at all.
**What this will not do.** It never moves, resets, or rewinds a branch, and
never requires base-equivalence — the committed work is the thing being
preserved. It never pushes and never opens a pull request. It touches only the
one lock file named by (remote, org, repo, issue). It accepts no caller-supplied
provenance and no caller-supplied authorization flag; both are minted
server-side. It refuses a healthy foreign-owned lock outright, and a matching
username alone is never accepted as proof of ownership — the profile must match
too, and the lock's recorded binding must agree with the observed branch,
worktree, and head.
"""
from __future__ import annotations
import os
from typing import Any, Mapping
import author_lock_contract
import issue_lock_store
#: Refusal codes, so callers can branch on cause rather than parse prose.
REFUSAL_NO_LOCK = "no_durable_lock"
REFUSAL_ALREADY_CANONICAL = "already_canonical"
REFUSAL_FOREIGN_CLAIMANT = "foreign_claimant"
REFUSAL_HEALTHY_FOREIGN = "healthy_foreign_lock"
REFUSAL_IDENTITY_UNRESOLVED = "identity_unresolved"
REFUSAL_BINDING_MISMATCH = "binding_mismatch"
REFUSAL_WORKTREE_INVALID = "worktree_invalid"
REFUSAL_HEAD_MISMATCH = "head_mismatch"
def _text(value: Any) -> str:
return str(value or "").strip()
def _same_realpath(left: str | None, right: str | None) -> bool:
lhs, rhs = _text(left), _text(right)
if not lhs or not rhs:
return False
try:
return os.path.realpath(lhs) == os.path.realpath(rhs)
except OSError:
return lhs == rhs
def assess_bootstrap_lock_recovery(
existing_lock: Mapping[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
observed_head: str | None,
declared_head: str | None,
worktree_exists: bool,
worktree_registered: bool,
current_branch: str | None,
now: Any = None,
) -> dict[str, Any]:
"""Decide whether this exact lock may be upgraded by this exact caller.
Pure: every input is an observation the caller already made, and nothing
here reads or writes the filesystem, git, or Gitea. That is what makes the
same decision testable in isolation and reusable by the read-only
inspection surface, which must not mutate anything (AC16).
Returns a dict with ``recovery_sanctioned`` plus the full evidence set. A
refusal never raises — it reports, so the caller can surface exactly which
piece of evidence was missing.
"""
reasons: list[str] = []
refusal_code: str | None = None
contract = author_lock_contract.assess_lock_contract(existing_lock)
if not existing_lock:
return {
"recovery_sanctioned": False,
"refusal_code": REFUSAL_NO_LOCK,
"reasons": [
f"no durable issue lock exists for issue #{issue_number}; there is "
"nothing to recover (fail closed)"
],
"contract": contract,
"evidence": {},
"expected_generation": None,
}
active_identity = _text(identity)
active_profile = _text(profile)
recorded = author_lock_contract.lock_claimant(existing_lock)
freshness = issue_lock_store.assess_lock_freshness(dict(existing_lock), now=now)
generation = issue_lock_store.lock_generation(existing_lock)
evidence: dict[str, Any] = {
"recorded_claimant": recorded,
"active_identity": active_identity,
"active_profile": active_profile,
"recorded_branch": existing_lock.get("branch_name"),
"recorded_worktree": existing_lock.get("worktree_path"),
"recorded_owner_session": existing_lock.get("owner_session"),
"recorded_generation": generation,
"recorded_remote": existing_lock.get("remote"),
"recorded_org": existing_lock.get("org"),
"recorded_repo": existing_lock.get("repo"),
"observed_head": _text(observed_head),
"declared_head": _text(declared_head),
"current_branch": _text(current_branch),
"worktree_exists": bool(worktree_exists),
"worktree_registered": bool(worktree_registered),
"freshness": freshness,
"claimant_placement": contract.get("claimant_placement"),
"expiration_state": contract.get("expiration", {}).get("state"),
}
# ── Repository and issue identity (AC10) ──
if _text(existing_lock.get("remote")) != _text(remote):
reasons.append(
f"recorded remote '{existing_lock.get('remote')}' does not match '{remote}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if _text(existing_lock.get("org")) != _text(org):
reasons.append(
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if _text(existing_lock.get("repo")) != _text(repo):
reasons.append(
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if existing_lock.get("issue_number") != issue_number:
reasons.append(
f"lock targets issue #{existing_lock.get('issue_number')}, not "
f"#{issue_number}"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
# ── Branch and worktree binding (AC10) ──
if _text(existing_lock.get("branch_name")) != _text(branch_name):
reasons.append(
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
f"'{branch_name}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if not _same_realpath(existing_lock.get("worktree_path"), worktree_path):
reasons.append(
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
f"match '{worktree_path}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
# ── The worktree is real, registered, and on the branch (AC10) ──
# Deliberately no base-equivalence requirement and no constraint on how far
# the branch has advanced: the whole point is that it already carries the
# author's legitimate commits (AC9).
if not worktree_exists:
reasons.append(f"declared worktree '{worktree_path}' does not exist")
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
if not worktree_registered:
reasons.append(f"worktree '{worktree_path}' is not a registered git worktree")
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
if _text(current_branch) != _text(branch_name):
reasons.append(
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
f"'{branch_name}'"
)
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
# ── Current head fencing (AC10) ──
# The caller names the commit it believes it is recovering. A mismatch means
# the worktree moved under the caller, so the decision is stale.
if not _text(observed_head):
reasons.append("could not observe the worktree head")
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
elif _text(declared_head) and _text(declared_head) != _text(observed_head):
reasons.append(
f"declared head '{_text(declared_head)}' does not match observed head "
f"'{_text(observed_head)}'"
)
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
# ── Ownership (AC10, AC11) ──
# A matching username alone is never sufficient: the profile must match too,
# and both are compared against server-resolved values the caller cannot set.
if not active_identity or not active_profile:
reasons.append(
"active identity and profile could not both be resolved; ownership "
"cannot be proven"
)
refusal_code = refusal_code or REFUSAL_IDENTITY_UNRESOLVED
if not recorded["username"] or not recorded["profile"]:
reasons.append(
"durable lock does not record both a claimant username and profile"
)
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
elif (
recorded["username"] != active_identity
or recorded["profile"] != active_profile
):
# AC11: a foreign-owned lock is never recoverable through this path,
# healthy or not. The healthy case is reported distinctly so the refusal
# is legible, but both refuse.
if freshness.get("live"):
reasons.append(
f"lock is owned by a healthy foreign claimant "
f"'{recorded['username']}/{recorded['profile']}'; takeover is not "
"a recovery path"
)
refusal_code = REFUSAL_HEALTHY_FOREIGN
else:
reasons.append(
f"lock claimant '{recorded['username']}/{recorded['profile']}' "
f"does not match active '{active_identity}/{active_profile}'"
)
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
# ── Nothing to recover ──
# A lock that is already canonical is left strictly alone. Rewriting it would
# mint a new task-session identifier and invalidate the heartbeat token the
# legitimate owner is already using.
if contract.get("canonical") and not reasons:
return {
"recovery_sanctioned": False,
"refusal_code": REFUSAL_ALREADY_CANONICAL,
"reasons": [
"lock already satisfies the canonical contract; no recovery is "
"required"
],
"contract": contract,
"evidence": evidence,
"expected_generation": generation,
}
sanctioned = not reasons
return {
"recovery_sanctioned": sanctioned,
"refusal_code": None if sanctioned else refusal_code,
"reasons": reasons,
"contract": contract,
"evidence": evidence,
"expected_generation": generation,
}
def build_recovery_record(
assessment: Mapping[str, Any],
*,
recovered_at: str,
new_task_session_id: str,
) -> dict[str, Any]:
"""Auditable record of the ownership and generation transition (AC10).
A recovered lock must never read as an original claim, so both sides of the
transition are preserved: what the incomplete lock recorded, and what
replaced it.
"""
evidence = dict(assessment.get("evidence") or {})
contract = dict(assessment.get("contract") or {})
return {
"recovery_kind": "incomplete_bootstrap_lock",
"recovered_at": recovered_at,
"prior_contract": contract.get("contract"),
"prior_missing_fields": list(contract.get("missing_fields") or []),
"prior_claimant_placement": evidence.get("claimant_placement"),
"prior_expiration_state": evidence.get("expiration_state"),
"prior_generation": evidence.get("recorded_generation"),
"prior_owner_session": evidence.get("recorded_owner_session"),
"prior_freshness": (evidence.get("freshness") or {}).get("status"),
"replacement_task_session_id": new_task_session_id,
"preserved_head": evidence.get("observed_head"),
"branch_reset": False,
"base_equivalence_required": False,
}
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
reasons = "; ".join(
assessment.get("reasons") or ["unknown bootstrap lock recovery refusal"]
)
code = assessment.get("refusal_code") or "refused"
return f"Bootstrap lock recovery refused ({code}): {reasons} (fail closed)"
+148
View File
@@ -0,0 +1,148 @@
# The canonical author issue-lock contract (#953)
Every author issue lock has exactly one shape. Both writers —
`gitea_bootstrap_author_issue_worktree` and `gitea_lock_issue` — build it
through `author_lock_contract.build_canonical_issue_lock`, and every reader
consumes that same shape.
Before #953 the two writers disagreed. `gitea_lock_issue` wrote the canonical
record; bootstrap wrote a thinner one with the claimant at the lock top level,
`lease_id: null`, and no `work_lease`, `lock_provenance`, or expiry. Because
every reader was written against the canonical shape, a lock that bootstrap
reported as successfully created could not be heartbeated, renewed, re-locked,
or accepted by `gitea_create_pr`. Each of those gates was individually correct;
the defect was that two writers disagreed about what a lock *is*.
## Required ordering
**Finalize the lock before writing any implementation bytes.** This ordering is
what keeps recovery cheap: while the worktree is still base-equivalent, a lock
problem can be fixed by simply calling `gitea_lock_issue` again. Once the branch
carries commits, base-equivalence is gone and the ordinary re-lock path is no
longer available.
1. `gitea_whoami` — resolve identity and profile.
2. `gitea_resolve_task_capability(task='work_issue')`.
3. `gitea_bootstrap_author_issue_worktree` — creates the branch, the registered
worktree under `branches/`, and a **canonical** lock. It reads the lock back
and verifies it structurally before reporting success; a partial lock fails
closed here, with the missing fields named, and never reports
`implementation_allowed: true`.
4. `gitea_heartbeat_issue_lock` — prove the lock is usable, using the
`task_session_id` bootstrap returned.
5. Implement, commit, push.
6. `gitea_create_pr`.
If bootstrap returns `success: false` with
`reason_code: incomplete_issue_lock_contract`, **do not implement**. Its
`exact_next_action` names the executable recovery step. Bootstrap's reported
next action always matches the state it actually returned.
## The contract
A canonical lock carries every field in
`author_lock_contract.REQUIRED_LOCK_FIELDS`:
| Field | Meaning |
| --- | --- |
| `remote`, `org`, `repo`, `issue_number` | repository and issue identity |
| `branch_name`, `worktree_path` | the binding this claim owns |
| `work_lease` | the canonical lease block, below |
| `lock_provenance` | sanctioned source, minted server-side |
| `lock_generation` | monotonic; every write advances it |
`work_lease` carries every field in
`author_lock_contract.REQUIRED_WORK_LEASE_FIELDS`, notably:
| Field | Meaning |
| --- | --- |
| `claimant.{username,profile}` | **canonical** claimant placement |
| `expires_at` | sliding TTL from `lease_policy` |
| `last_heartbeat_at`, `heartbeat_count` | liveness evidence |
| `task_session_id` | the ownership fencing token — never null |
| `lifecycle_version` | `heartbeat-v1`; its absence is what makes a lock legacy |
### Claimant placement and legacy compatibility
`work_lease.claimant` is canonical. A top-level `claimant` is the legacy
placement written by pre-#953 bootstrap and is still **read** — through the one
shared reader, `issue_lock_store.lock_claimant` — so an existing lock is not
refused for "not recording a claimant" when it plainly records one.
Tolerating the placement is not a widening. Every caller still compares the
values against server-resolved identity and profile, so a legacy placement
grants nothing the canonical placement would not. When both are present, the
`work_lease` copy wins: after an upgrade, a stale top-level copy must never
decide ownership.
### Expiration is explicit
A lock with no recorded expiry is **not** "not yet expired". `is_lease_expired`
returns `False` for it, which used to make such a lock permanently non-expiring
*and* permanently ineligible for #760 exact-owner renewal, which only ever
assesses an expired lease. `author_lock_contract.expiration_state` names the
real fact: `recorded`, `missing`, or `unparseable`. A `missing` expiry makes the
lock eligible for the recovery path below rather than stranding it.
## Recovering an existing incomplete bootstrap lock
For locks already written by the old bootstrap — including those whose branches
already carry legitimate committed and pushed work — use:
```text
gitea_inspect_issue_lock_contract(issue_number, branch_name, worktree_path, remote=...)
gitea_recover_incomplete_bootstrap_lock(issue_number, branch_name, worktree_path, expected_head, remote=...)
```
`gitea_inspect_issue_lock_contract` is strictly read-only: it performs no lock,
lease, branch, worktree, issue, or pull-request mutation. Use it first to see
which fields are missing and what the recommended action is; pass `dry_run=True`
to the recovery tool to preview the decision without writing.
`gitea_recover_incomplete_bootstrap_lock` upgrades that one lock to the
canonical contract. Before writing anything it verifies:
* repository (`remote`, `org`, `repo`) and issue number
* claimant username **and** profile against the server-resolved values — a
matching username alone is never accepted
* branch, worktree path, worktree existence, and worktree registration
* the worktree is on the recorded branch
* the observed head equals the caller's `expected_head`
* the existing lock's generation and provenance state
* the absence of healthy foreign ownership
What it deliberately does **not** do:
* it never moves, resets, or rewinds the branch, and never requires
base-equivalence — preserving the committed work is the entire point;
* it never pushes and never creates a pull request;
* it touches only the single lock file for that exact remote/org/repo/issue;
* it accepts no caller-supplied provenance and no caller-supplied authorization
flag — both are minted server-side.
A recovered lock records a `bootstrap_lock_recovery` block holding both sides of
the transition — prior contract, prior missing fields, prior generation, prior
owning session, the replacement `task_session_id`, and the preserved head — so a
recovered claim never reads as an original one.
### Refusals
| `refusal_code` | Meaning |
| --- | --- |
| `no_durable_lock` | nothing to recover |
| `already_canonical` | lock is fine; rewriting would invalidate a live heartbeat token |
| `foreign_claimant` | recorded claimant is not the active identity/profile pair |
| `healthy_foreign_lock` | a live foreign-owned lock; takeover is not a recovery path |
| `identity_unresolved` | identity or profile could not be resolved |
| `binding_mismatch` | repository, issue, branch, or worktree does not match |
| `worktree_invalid` | worktree missing, unregistered, or on another branch |
| `head_mismatch` | the worktree moved under the caller |
## The #447 create-PR provenance guard is unchanged
`issue_lock_provenance.assess_lock_file_for_create_pr` still requires both a
sanctioned `lock_provenance` and a `work_lease`, and the sanctioned source set
was **not** widened. Bootstrap writes through
`issue_lock_provenance.SOURCE_LOCK_ISSUE` — the lock it produces *is* a
canonical lock, not a second dialect with its own exemption. Bootstrap now
satisfies the guard rather than the guard being relaxed to admit bootstrap.
+362 -27
View File
@@ -2110,6 +2110,8 @@ import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402 import issue_lock_adoption # noqa: E402
import issue_lock_recovery # noqa: E402 import issue_lock_recovery # noqa: E402
import issue_lock_renewal # 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_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery
import dirty_same_claimant_session_rebind # noqa: E402 # #864 import dirty_same_claimant_session_rebind # noqa: E402 # #864
import stacked_pr_support # noqa: E402 import stacked_pr_support # noqa: E402
@@ -2658,33 +2660,17 @@ def _build_author_issue_work_lease(
worktree_path: str, worktree_path: str,
host: str | None, host: str | None,
) -> dict: ) -> dict:
created = _work_lease_now() # #953: the lease shape now lives in author_lock_contract so that bootstrap
# #790 Slice A: the window comes from the central policy, not a literal here. # and gitea_lock_issue cannot drift apart again. The policy-derived sliding
# It is also now a *sliding* window — the lease lives ``initial_ttl_minutes`` # TTL (#790 Slice A) and the task-session ownership key (#790 AC-N1) are
# past its last valid heartbeat rather than a fixed four hours past its # unchanged — they simply have one definition instead of two.
# creation, so an abandoned task stops holding the claim within one TTL. return author_lock_contract.build_author_issue_work_lease(
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK) issue_number=issue_number,
expires = created + timedelta(minutes=policy.initial_ttl_minutes) branch_name=branch_name,
return { worktree_path=worktree_path,
"operation_type": AUTHOR_ISSUE_WORK_LEASE, claimant=_work_lease_claimant(host),
"issue_number": issue_number, created=_work_lease_now(),
"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,
}
def _active_work_lease_block( def _active_work_lease_block(
@@ -4954,6 +4940,355 @@ def gitea_heartbeat_issue_lock(
return outcome 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() @mcp.tool()
def gitea_recover_dirty_orphaned_issue_worktree( def gitea_recover_dirty_orphaned_issue_worktree(
issue_number: int, issue_number: int,
+35 -9
View File
@@ -299,9 +299,13 @@ def _ownership_refusals(
f"lock worktree '{lock.get('worktree_path')}' does not match " f"lock worktree '{lock.get('worktree_path')}' does not match "
f"'{worktree_path}'" f"'{worktree_path}'"
) )
lease = lock.get("work_lease") if isinstance(lock, dict) else None # #953 AC2/AC13/AC14: read through the shared claimant reader so a lock
claimant = lease.get("claimant") if isinstance(lease, dict) else None # written by bootstrap — which records the claimant at the top level — is
claimant = claimant if isinstance(claimant, dict) else {} # 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_identity = str(claimant.get("username") or "").strip()
recorded_profile = str(claimant.get("profile") or "").strip() recorded_profile = str(claimant.get("profile") or "").strip()
if not recorded_identity or not recorded_profile: 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): if not isinstance(lock, dict):
return {} 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): if not isinstance(claimant, dict):
lease = lock.get("work_lease") claimant = lock.get("claimant")
claimant = lease.get("claimant") if isinstance(lease, dict) else None
if not isinstance(claimant, dict): if not isinstance(claimant, dict):
return {} return {}
return { return {
"username": str(claimant.get("username") or ""), "username": str(claimant.get("username") or "").strip(),
"profile": str(claimant.get("profile") or ""), "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( def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None, existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any], incoming_lock: dict[str, Any],
+21
View File
@@ -41,6 +41,27 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
"role": "author", "role": "author",
}, },
# #953: target-specific upgrade of an incomplete bootstrap lock (explicit
# operation, never a widening of lock_issue). Author-only, and the tool
# additionally proves exact-owner claimant match before writing.
"recover_incomplete_bootstrap_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
"gitea_recover_incomplete_bootstrap_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
# #953: read-only lock contract inspection. Read permission only — it must
# never be able to mutate.
"inspect_issue_lock_contract": {
"permission": "gitea.read",
"role": "author",
},
"gitea_inspect_issue_lock_contract": {
"permission": "gitea.read",
"role": "author",
},
# #860: dirty orphaned same-claimant worktree recovery (explicit operation). # #860: dirty orphaned same-claimant worktree recovery (explicit operation).
"recover_dirty_orphaned_issue_worktree": { "recover_dirty_orphaned_issue_worktree": {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
File diff suppressed because it is too large Load Diff