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:
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user