Merge pull request 'fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)' (#954) from fix/issue-953-bootstrap-lock-provenance into master

Merges PR #954 at approved head b4c9f55890.

Approval: review 633 APPROVE at b4c9f55890.
Base: master at 82d71b7702.

Closes #953
This commit was merged in pull request #954.
This commit is contained in:
2026-07-28 02:21:11 -05:00
11 changed files with 3838 additions and 62 deletions
+134 -24
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
@@ -278,6 +279,36 @@ def _verify_assignment_and_lease_ids(
return None return None
def _branch_exists(canonical_repo_root: str, branch_name: str) -> bool:
"""Whether *branch_name* still resolves in the canonical checkout (#953 F2).
Used after compensating recovery to observe what survived rather than infer
it from the journal. Fails closed to ``True``: an unobservable branch is
reported as present, so the recommendation stays conservative rather than
telling an author to re-bootstrap over something that may still be there.
"""
if not branch_name:
return False
try:
res = subprocess.run(
[
"git",
"-C",
canonical_repo_root,
"rev-parse",
"--verify",
"--quiet",
f"refs/heads/{branch_name}",
],
capture_output=True,
text=True,
check=False,
)
except Exception:
return True
return res.returncode == 0
def run_compensating_recovery( def run_compensating_recovery(
journal: dict[str, Any], journal: dict[str, Any],
canonical_repo_root: str, canonical_repo_root: str,
@@ -314,10 +345,21 @@ def run_compensating_recovery(
issue_number=issue_num, issue_number=issue_num,
session=session_id, session=session_id,
lock_dir=journal_dir, lock_dir=journal_dir,
remote=journal.get("remote"),
# The same defaults the lock was written under, so the
# rollback targets the exact file bind_session_lock keyed.
org=journal.get("org") or "Scaled-Tech-Consulting",
repo=journal.get("repo") or "Gitea-Tools",
) )
rolled_back.append(f"lock:issue-{issue_num}") rolled_back.append(f"lock:issue-{issue_num}")
except Exception: except Exception as exc:
pass # #953 F2: a swallowed failure here is what made the rollback
# report success while leaving an unrecoverable lock behind.
# Record it so the post-compensation classification can see the
# lock survived and recommend accordingly.
rolled_back.append(
f"lock_release_failed:issue-{issue_num}:{type(exc).__name__}"
)
artifacts["lock_created"] = False artifacts["lock_created"] = False
worktree_created = ( worktree_created = (
@@ -1198,26 +1240,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 +1282,65 @@ 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
)
compensation = run_compensating_recovery(
journal, root, journal_dir=lock_dir
)
# AC5/AC15: the recommendation must describe the state compensation
# actually left, not the state that provoked it.
# ``run_compensating_recovery`` has by now released the lock, removed
# the worktree, and deleted the branch, so recommending
# incomplete-lock recovery for those exact artifacts would refuse
# twice over. Observe what survived and answer for that.
post_state = author_lock_contract.assess_post_compensation_state(
compensation,
lock_present=bool(lock_res) and os.path.exists(lock_res),
worktree_present=os.path.isdir(target_worktree),
branch_present=_branch_exists(root, target_branch),
)
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,
"compensating_recovery": compensation,
"post_compensation_state": post_state,
# AC15: never strand a branch or worktree without a structured
# recovery recommendation — and never name an artifact the
# rollback has already deleted.
"exact_next_action": author_lock_contract.post_compensation_action(
post_state,
issue_number=issue_number,
branch_name=target_branch,
worktree_path=target_worktree,
missing_fields=contract["missing_fields"],
),
"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 +1364,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),
} }
+623
View File
@@ -0,0 +1,623 @@
"""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) deliberately writes
# through SOURCE_LOCK_ISSUE too, and records its distinctness in
# ``lock_provenance.written_by_tool`` plus the ``bootstrap_lock_recovery``
# transition block instead. There is no distinct recovery *source* constant, for
# the same reason bootstrap has none: minting one would require widening
# SANCTIONED_LOCK_SOURCES, which the #447 safety requirements forbid.
#: 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."
)
# ── Post-compensation recovery guidance (#953 AC5/AC15, review 632 F2) ──
#
# ``recommended_action`` above answers "what can be done about a lock in this
# shape". That is the wrong question on the bootstrap AC7 refusal path, because
# ``run_compensating_recovery`` has already run by the time the answer is
# reported: it releases the lock, removes the worktree when clean — which it
# always is there, no implementation bytes having been written — and deletes the
# created branch. Recommending incomplete-lock recovery for those artifacts
# hands the author two refusals in a row (``no_durable_lock``, then
# ``worktree_invalid``) for a state that a plain bootstrap retry would fix. The
# advice must describe the state that actually *remains*.
#: Compensation removed every artifact this transition created.
CLEANUP_COMPLETE = "complete"
#: Compensation removed some artifacts; others survive and are still actionable.
CLEANUP_PARTIAL = "partial"
#: Compensation itself failed or could not be observed; nothing is provable.
CLEANUP_FAILED = "failed"
def assess_post_compensation_state(
recovery: Mapping[str, Any] | None,
*,
lock_present: bool,
worktree_present: bool,
branch_present: bool,
) -> dict[str, Any]:
"""Classify what survived compensation, from observed durable state.
Pure. The caller observes the filesystem and git; this decides. Observation
is authoritative over the journal's ``rolled_back`` list, which records what
compensation *attempted*: ``run_compensating_recovery`` swallows a failed
lock release and appends nothing, so an absent marker proves nothing either
way. The list is still carried through as corroborating evidence.
The three states are distinct facts, not degrees of the same one:
* ``CLEANUP_COMPLETE`` — compensation ran and nothing it created remains.
* ``CLEANUP_PARTIAL`` — compensation ran and artifacts survive, whether by
design (a worktree dirty at rollback time, a branch carrying commits) or
because a rollback step errored. Either way the surviving set was observed
directly, so it is known and actionable; ``failed_rollback_steps`` records
which cause applies.
* ``CLEANUP_FAILED`` — compensation never ran to completion, so nothing it
would have removed can be assumed removed.
"""
rolled_back = list((recovery or {}).get("rolled_back") or [])
executed = bool((recovery or {}).get("executed"))
failed_steps = [entry for entry in rolled_back if "_failed" in entry]
surviving: list[str] = []
if lock_present:
surviving.append("lock")
if worktree_present:
surviving.append("worktree")
if branch_present:
surviving.append("branch")
if not executed:
state = CLEANUP_FAILED
elif surviving:
state = CLEANUP_PARTIAL
else:
state = CLEANUP_COMPLETE
return {
"cleanup_state": state,
"compensation_executed": executed,
"lock_present": bool(lock_present),
"worktree_present": bool(worktree_present),
"branch_present": bool(branch_present),
"surviving_artifacts": surviving,
"removed_artifacts": [
name
for name, present in (
("lock", lock_present),
("worktree", worktree_present),
("branch", branch_present),
)
if not present
],
"failed_rollback_steps": failed_steps,
"rolled_back": rolled_back,
}
def post_compensation_action(
state: Mapping[str, Any],
*,
issue_number: int,
branch_name: str,
worktree_path: str,
missing_fields: list[str] | None = None,
) -> str:
"""The one executable next step for the state compensation actually left.
Every branch names only artifacts the classification says still exist, so no
recommendation can point at something the rollback deleted.
"""
missing = ", ".join(missing_fields or []) or "the reported missing fields"
cleanup_state = state.get("cleanup_state")
lock_present = bool(state.get("lock_present"))
worktree_present = bool(state.get("worktree_present"))
branch_present = bool(state.get("branch_present"))
if not state.get("compensation_executed"):
# Compensation never ran, so nothing was rolled back and nothing about
# the remaining state was decided. The read-only surface is the only
# action executable under any state.
return (
"Compensating rollback did not complete, so the remaining state is "
f"not proven. Call gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to establish what survives before any "
"further action. Do not retry bootstrap until it is known."
)
prefix = ""
failed_steps = state.get("failed_rollback_steps") or []
if failed_steps:
prefix = (
"Compensating rollback reported a failed step "
f"({', '.join(failed_steps)}); what survives was observed directly "
"and the action below is scoped to exactly that. "
)
if cleanup_state == CLEANUP_COMPLETE:
return (
"Compensating rollback removed the malformed lock, the branch, and "
f"the worktree, so nothing from this attempt remains. Resolve "
f"{missing} and re-run gitea_bootstrap_author_issue_worktree for "
f"issue #{issue_number} from the clean pre-bootstrap state. Do not "
"call gitea_recover_incomplete_bootstrap_lock: there is no lock, "
"branch, or worktree left for it to act on."
)
if lock_present and worktree_present and branch_present:
return prefix + (
"The lock, branch, and worktree all survive. Call "
"gitea_recover_incomplete_bootstrap_lock for issue "
f"#{issue_number}, branch '{branch_name}', and worktree "
f"'{worktree_path}', passing the worktree's current head as "
"expected_head, to upgrade the lock to the canonical contract."
)
if not lock_present and worktree_present and branch_present:
return prefix + (
"The malformed lock was released but the branch and worktree "
"survive. No implementation bytes were written, so the worktree is "
f"still base-equivalent: call gitea_lock_issue for issue "
f"#{issue_number} on branch '{branch_name}' from worktree "
f"'{worktree_path}' to acquire a canonical lock."
)
if lock_present and not worktree_present:
return prefix + (
f"The worktree for issue #{issue_number} is gone but the durable "
"lock survived, so neither gitea_recover_incomplete_bootstrap_lock "
"(it would refuse worktree_invalid) nor gitea_lock_issue (it has no "
"worktree to bind) is executable. Call "
"gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to confirm the surviving lock; it "
"must be released by its recorded owner before bootstrap is "
"retried."
)
# Lock gone, worktree gone, some git artifact left (a branch with commits,
# or a branch this transition did not create).
return prefix + (
"Compensating rollback removed the lock and worktree; branch "
f"'{branch_name}' survives and was not deleted. Call "
f"gitea_inspect_issue_lock_contract for issue #{issue_number} "
"(read-only) to confirm no durable lock remains, then re-run "
"gitea_bootstrap_author_issue_worktree, which will adopt the existing "
"branch rather than recreating it."
)
+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)"
+209
View File
@@ -0,0 +1,209 @@
# 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.
### What that refusal leaves behind
The AC7 refusal runs `run_compensating_recovery` *before* it reports, so the
advice has to describe the post-rollback state rather than the shape of the lock
that provoked it. Recommending incomplete-lock recovery for artifacts the
rollback already deleted would produce `no_durable_lock` and then
`worktree_invalid` — two refusals for a state a plain retry fixes.
The refusal therefore carries `compensating_recovery` and
`post_compensation_state`, and derives `exact_next_action` from what was
observed on disk. `cleanup_state` is one of:
| `cleanup_state` | Meaning | Next action |
| --- | --- | --- |
| `complete` | lock, branch, and worktree all removed | resolve `missing_fields` and re-run `gitea_bootstrap_author_issue_worktree` |
| `partial` | rollback ran; some artifacts survive, by design or because a step errored | scoped to exactly what survives — see below |
| `failed` | rollback never completed, so nothing is proven removed | `gitea_inspect_issue_lock_contract` (read-only) before anything else |
Within `partial`, the surviving set decides the action:
| Survives | Next action |
| --- | --- |
| lock + branch + worktree | `gitea_recover_incomplete_bootstrap_lock` for that exact issue, branch, and worktree |
| branch + worktree (lock released) | `gitea_lock_issue` — no implementation bytes were written, so the worktree is still base-equivalent |
| lock only (worktree removed) | `gitea_inspect_issue_lock_contract`; the surviving lock must be released by its recorded owner before bootstrap is retried |
| branch only | `gitea_inspect_issue_lock_contract`, then re-run bootstrap, which adopts the existing branch |
`failed_rollback_steps` names any rollback step that errored, and the returned
action says so rather than presenting the surviving state as intentional.
> The lock half of that rollback was dead code until #953 review 632 F2:
> `run_compensating_recovery` called `issue_lock_store.release_session_lock`,
> which did not exist, inside a bare `except Exception: pass`. Every rollback
> removed the branch and worktree and silently left the lock — the exact
> uninspectable, unrecoverable state this issue exists to eliminate. The
> function now exists, releases only a lock whose recorded `owner_session`
> matches, and its failures are recorded rather than swallowed.
## 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.
### Gates, in order
`gitea_recover_incomplete_bootstrap_lock` is an author-only durable-lock
mutation and carries the same three gates as every comparable author operation,
in this order:
1. `role_session_router.check_author_mutation_after_reviewer_stop` — no author
fallback after a reviewer `wrong_role_stop`.
2. `_namespace_mutation_block(task, remote=remote, author_role_exclusive=True)`
the namespace wall. It refuses a reviewer-bound session and, because this
task's required permission is `gitea.issue.comment` (which merger,
controller, and reconciler profiles also hold), additionally requires the
active profile's derived role kind to be exactly `author`. A refusal carries
`namespace_block: true` and emits a `BLOCKED` audit record naming the
namespace and profile.
3. `_profile_permission_block` — operation, provenance, and session-context
gates.
Exact-owner claimant matching inside `assess_bootstrap_lock_recovery` runs
*after* all three. It is a further layer, never a substitute for them: on its
own it refuses one step too late and leaves the audit trail silent about the
attempt.
### 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.
+2
View File
@@ -103,6 +103,7 @@ that gates each call, not which tools exist.
- `gitea_get_shell_health` - `gitea_get_shell_health`
- `gitea_heartbeat_issue_lock` - `gitea_heartbeat_issue_lock`
- `gitea_heartbeat_reviewer_pr_lease` - `gitea_heartbeat_reviewer_pr_lease`
- `gitea_inspect_issue_lock_contract`
- `gitea_inspect_workflow_lease` - `gitea_inspect_workflow_lease`
- `gitea_issue_irrecoverable_provenance_authorization` - `gitea_issue_irrecoverable_provenance_authorization`
- `gitea_list_dependency_edges` - `gitea_list_dependency_edges`
@@ -134,6 +135,7 @@ that gates each call, not which tools exist.
- `gitea_record_pre_review_command` - `gitea_record_pre_review_command`
- `gitea_record_shell_spawn_outcome` - `gitea_record_shell_spawn_outcome`
- `gitea_record_stable_branch_push_attempt` - `gitea_record_stable_branch_push_attempt`
- `gitea_recover_incomplete_bootstrap_lock`
- `gitea_release_merger_pr_lease` - `gitea_release_merger_pr_lease`
- `gitea_release_reviewer_pr_lease` - `gitea_release_reviewer_pr_lease`
- `gitea_release_workflow_lease` - `gitea_release_workflow_lease`
+395 -29
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,370 @@ 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)
# #953 F1: the namespace/session wall every author state-creating mutation
# carries, and which this tool — the structural neighbour of
# gitea_recover_dirty_orphaned_issue_worktree, writing the same durable
# lock — was the only one to omit. Exact-owner claimant matching inside
# assess_bootstrap_lock_recovery is a later layer, not a substitute: it
# refuses one commit too late and leaves no BLOCKED audit record of the
# attempt. author_role_exclusive is required here because this task is gated
# on gitea.issue.comment, which merger, controller, and reconciler profiles
# also hold.
blocked = _namespace_mutation_block(
task, remote=remote, author_role_exclusive=True
)
if blocked:
return blocked
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,
@@ -15155,8 +15505,21 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
) )
def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None: def _namespace_mutation_block(
"""Reviewer/author namespace alignment gate (#209).""" mutation_task: str,
*,
author_role_exclusive: bool = False,
**extra_fields,
) -> dict | None:
"""Reviewer/author namespace alignment gate (#209).
``author_role_exclusive`` additionally requires the active profile's derived
role kind to be exactly ``author`` (#953 F1). Off by default, so the six
pre-existing call sites are unchanged. Tools whose required permission is
``gitea.issue.comment`` which every configured role holds opt in, since
the reviewer-namespace check alone would let a merger, controller, or
reconciler session through to a durable author lock write.
"""
required_permission = task_capability_map.required_permission(mutation_task) required_permission = task_capability_map.required_permission(mutation_task)
required_role = task_capability_map.required_role(mutation_task) required_role = task_capability_map.required_role(mutation_task)
# #714: evaluate active profile only — never auto-switch. # #714: evaluate active profile only — never auto-switch.
@@ -15178,6 +15541,9 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
} }
ok, reasons = role_namespace_gate.check_author_mutation_namespace( ok, reasons = role_namespace_gate.check_author_mutation_namespace(
mutation_task, profile) mutation_task, profile)
if ok and author_role_exclusive:
ok, reasons = role_namespace_gate.check_author_role_kind(
mutation_task, profile)
if ok: if ok:
return None return None
blocked = { blocked = {
+128 -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:
@@ -636,6 +640,99 @@ def iter_lock_files(lock_dir: str | None = None) -> list[str]:
return sorted(paths) return sorted(paths)
def release_session_lock(
*,
issue_number: int,
session: str,
lock_dir: str | None = None,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> str:
"""Remove exactly the durable lock *session* created for *issue_number*.
``author_issue_bootstrap.run_compensating_recovery`` has called this name
since #850, but it was never defined: the call raised ``AttributeError``
into a bare ``except Exception: pass``, so the lock half of every
compensating rollback silently did nothing. The branch and worktree were
removed and the lock was left behind — a state no sanctioned tool can act
on, since recovery refuses ``worktree_invalid`` and ``gitea_lock_issue`` has
no worktree to bind (#953 review 632 F2).
Ownership is proven, not asserted. A record is removed only when its
recorded ``owner_session`` equals *session* and its issue number matches;
``remote``/``org``/``repo`` narrow it further when supplied. Zero matches or
more than one both raise, so a caller can never delete a lock it does not
own and an ambiguous directory is never guessed at. The ``.json.lock`` flock
sidecar is deliberately left in place — it is a zero-byte mutex another
process may hold, and removing it under contention would be a race.
Returns the removed lock file path.
"""
target_issue = int(issue_number)
owner = str(session or "").strip()
if not owner:
raise ValueError(
"release_session_lock requires the owning session id (fail closed)"
)
def _is_owned_durable_lock(record: dict[str, Any] | None) -> bool:
# A durable lock, not a bootstrap phase journal or a session pointer,
# both of which can share a directory and carry the same issue number
# and owner_session.
if not record or "lock_generation" not in record:
return False
if not str(record.get("branch_name") or "").strip():
return False
if not str(record.get("worktree_path") or "").strip():
return False
try:
if int(record.get("issue_number") or 0) != target_issue:
return False
except (TypeError, ValueError):
return False
return str(record.get("owner_session") or "").strip() == owner
# Prefer the exact keyed path when the caller knows the repository; scanning
# is the fallback for callers that only carry the issue number.
if remote and org and repo:
exact = lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=target_issue,
lock_dir=lock_dir,
)
if not _is_owned_durable_lock(read_lock_file(exact)):
raise FileNotFoundError(
f"durable issue lock '{exact}' is absent or is not owned by "
f"session '{owner}' (fail closed; nothing released)"
)
os.remove(exact)
return exact
matches: list[str] = []
for path in iter_lock_files(lock_dir):
if _is_owned_durable_lock(read_lock_file(path)):
matches.append(path)
if not matches:
raise FileNotFoundError(
f"no durable issue lock for issue #{target_issue} is owned by "
f"session '{owner}' (fail closed; nothing released)"
)
if len(matches) > 1:
raise RuntimeError(
f"{len(matches)} durable locks for issue #{target_issue} claim "
f"session '{owner}'; refusing to guess which to release "
"(fail closed)"
)
path = matches[0]
os.remove(path)
return path
def find_lock_for_branch( def find_lock_for_branch(
*, *,
remote: str, remote: str,
@@ -1112,21 +1209,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],
+37
View File
@@ -74,6 +74,43 @@ def check_author_mutation_namespace(
return True, [] return True, []
def check_author_role_kind(
mutation_task: str,
profile: dict,
) -> tuple[bool, list[str]]:
"""Author-exclusive wall for durable-lock mutations (#953 F1).
``check_author_mutation_namespace`` walls off reviewer-bound sessions, which
is the whole gate for tasks whose required permission is itself author-only
(``gitea.pr.create``, ``gitea.repo.commit``). It is *not* sufficient for a
task gated on ``gitea.issue.comment``, which every configured role holds: a
merger, controller, or reconciler session would clear both the namespace
check and the permission gate and still reach the durable write.
Opt-in per call site and additive. It refuses any active profile whose
derived role kind is not exactly ``author`` for a task the router declares
author-required, and grants nothing to anyone — a ``mixed`` profile is
refused rather than admitted.
"""
required_role = role_session_router.required_role_for_task(mutation_task)
if required_role != "author":
return True, []
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
active_role = derive_role_kind(allowed, forbidden)
if active_role == "author":
return True, []
profile_name = profile.get("profile_name") or ""
namespace = infer_mcp_namespace(profile_name)
return False, [
f"author mutation '{mutation_task}' blocked: active session role kind is "
f"'{active_role}', not 'author' ({profile_name} / {namespace}); this "
"operation writes a durable author issue lock and is author-exclusive",
]
def mutation_audit_context(mutation_task: str, profile: dict, *, def mutation_audit_context(mutation_task: str, profile: dict, *,
remote=None, repository=None) -> dict: remote=None, repository=None) -> dict:
"""Structured mutation metadata for audit records (#209).""" """Structured mutation metadata for audit records (#209)."""
+10
View File
@@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({
"push_branch", "push_branch",
"bootstrap_author_issue_worktree", "bootstrap_author_issue_worktree",
"gitea_bootstrap_author_issue_worktree", "gitea_bootstrap_author_issue_worktree",
# #953: recovery of an incomplete bootstrap lock is an author-only durable
# state mutation and belongs to the same class as bootstrap itself.
"recover_incomplete_bootstrap_lock",
"gitea_recover_incomplete_bootstrap_lock",
"create_pr", "create_pr",
"comment_pr", "comment_pr",
"address_pr_change_requests", "address_pr_change_requests",
@@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = {
"claim_issue": "author", "claim_issue": "author",
"create_branch": "author", "create_branch": "author",
"push_branch": "author", "push_branch": "author",
# #953: without this entry ``required_role_for_task`` returns None and
# ``role_namespace_gate.check_author_mutation_namespace`` short-circuits to
# "allowed" — the namespace wall on the recovery tool would be inert. The
# capability map already records the same role; both tables must agree.
"recover_incomplete_bootstrap_lock": "author",
"gitea_recover_incomplete_bootstrap_lock": "author",
"create_pr": "author", "create_pr": "author",
"comment_pr": "author", "comment_pr": "author",
"address_pr_change_requests": "author", "address_pr_change_requests": "author",
+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