Review 632 F2. The bootstrap AC7 read-back refusal calls `run_compensating_recovery` and *then* reported `author_lock_contract.recommended_action(contract)` — advice computed from the malformed lock that provoked the rollback, not from the state the rollback left. Compensation releases the lock, removes the worktree (always clean there, no implementation bytes having been written), and deletes the branch, so an author following that advice got `no_durable_lock` from `gitea_recover_incomplete_bootstrap_lock` and, had the lock survived, `worktree_invalid` instead; the `gitea_lock_issue` half of the same sentence cannot bind a worktree that no longer exists. Two refusals in a row for a state a plain bootstrap retry fixes — the unexecutable-guidance failure class this issue exists to remove, reintroduced on the new fail-closed path. Investigating that path surfaced why the "clean retry" state was in practice unreachable: `run_compensating_recovery` has called `issue_lock_store.release_session_lock` since #850, and that function has never existed. The `AttributeError` landed in a bare `except Exception: pass`, so every rollback removed the branch and worktree and silently left the lock behind — precisely the uninspectable, unrecoverable state #953 is about (`gitea_recover_incomplete_bootstrap_lock` refuses `worktree_invalid`, `gitea_lock_issue` has no worktree to bind). Confirmed dead at the pinned base `82d71b77`, not introduced by this branch. `release_session_lock` is therefore implemented: it removes exactly one durable lock whose recorded `owner_session` matches the caller's, keyed by repository when known, refusing on zero or multiple matches so no caller can delete a lock it does not own and an ambiguous directory is never guessed at. Bootstrap phase journals and session pointers that share the directory are excluded by shape. The flock sidecar is deliberately left alone. The caller no longer swallows a release failure; it records `lock_release_failed:...`. `assess_post_compensation_state` then classifies from directly observed durable state — lock file, worktree directory, and branch ref — rather than from the journal's `rolled_back` list, which records only what compensation attempted. Three distinct states: `complete` (nothing remains), `partial` (rollback ran, artifacts survive by design or because a step errored), `failed` (rollback never completed, so nothing is proven removed). `post_compensation_action` answers for exactly what survives: complete -> re-run gitea_bootstrap_author_issue_worktree lock + branch + worktree -> gitea_recover_incomplete_bootstrap_lock branch + worktree, no lock -> gitea_lock_issue (still base-equivalent) lock only, worktree gone -> gitea_inspect_issue_lock_contract branch only -> gitea_inspect_issue_lock_contract, then retry rollback did not complete -> gitea_inspect_issue_lock_contract No branch names an artifact the classification says is gone, and a failed rollback step is stated rather than presented as an intentional outcome. The refusal payload carries `compensating_recovery` and `post_compensation_state` alongside the derived `exact_next_action`, still with `implementation_allowed: false`. Also removes the dead `SOURCE_BOOTSTRAP_LOCK_RECOVERY` constant (review 632 F3), which had no readers and implied a second lock source; the deliberate reuse of `SOURCE_LOCK_ISSUE` is now stated as a comment. The #447 guard and `SANCTIONED_LOCK_SOURCES` remain untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1380 lines
56 KiB
Python
1380 lines
56 KiB
Python
"""Sanctioned author issue worktree bootstrap for allocated issues (#850).
|
|
|
|
Bootstraps an allocated author branch, canonical worktree under ``branches/``,
|
|
worktree registration, issue lock, and lease/assignment binding without
|
|
caller-side Git, Bash, or helper scripts.
|
|
|
|
Features:
|
|
1. Durable phase journal with read-after-write evidence for every phase.
|
|
2. Idempotent replay handling via idempotency keys.
|
|
3. Authoritative expected-base / concurrency-pin validation.
|
|
4. Typed stale-pin refusal without silent rebasing or repointing.
|
|
5. Canonical branches-root enforcement (path MUST be inside branches/).
|
|
6. Preexisting work preservation (dirty tracked/untracked check, foreign ownership refusal).
|
|
7. Compensating recovery limited strictly to artifacts created by this transition.
|
|
8. Satisfiable exact_next_action for MCP scheduled workers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from typing import Any, Mapping
|
|
|
|
import author_lock_contract
|
|
import author_mutation_worktree
|
|
import control_plane_db
|
|
import issue_lock_store
|
|
import issue_lock_worktree
|
|
import lease_lifecycle
|
|
from reviewer_worktree import parse_dirty_tracked_files
|
|
import task_capability_map
|
|
|
|
BOOTSTRAP_TASKS = frozenset(
|
|
{
|
|
"bootstrap_author_issue_worktree",
|
|
"gitea_bootstrap_author_issue_worktree",
|
|
}
|
|
)
|
|
|
|
PHASE_1_REQUEST_ACCEPTED = "1_request_accepted"
|
|
PHASE_2_BRANCH_CONFIRMED = "2_branch_confirmed"
|
|
PHASE_3_PATH_RESERVED = "3_path_reserved"
|
|
PHASE_4_WORKTREE_CONFIRMED = "4_worktree_confirmed"
|
|
PHASE_5_REGISTRATION_VERIFIED = "5_registration_verified"
|
|
PHASE_6_STATE_ESTABLISHED = "6_state_established"
|
|
PHASE_7_TRANSITION_COMPLETED = "7_transition_completed"
|
|
PHASE_COMPENSATING_RECOVERY = "compensating_recovery"
|
|
|
|
JOURNAL_DIR_NAME = "bootstrap-journals"
|
|
|
|
|
|
def is_author_issue_bootstrap_task(task: str | None) -> bool:
|
|
"""True when *task* is the author issue worktree bootstrap task."""
|
|
return (task or "").strip() in BOOTSTRAP_TASKS
|
|
|
|
|
|
def get_journal_dir(override: str | None = None) -> str:
|
|
"""Return the root directory for durable bootstrap phase journals."""
|
|
if override:
|
|
path = override
|
|
elif os.environ.get("GITEA_BOOTSTRAP_JOURNAL_DIR"):
|
|
path = os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"]
|
|
else:
|
|
cache_dir = os.path.expanduser("~/.cache/gitea-tools")
|
|
path = os.path.join(cache_dir, JOURNAL_DIR_NAME)
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _journal_file_path(idempotency_key: str, journal_dir: str | None = None) -> str:
|
|
safe_key = "".join(
|
|
c if c.isalnum() or c in ("-", "_", ".") else "_"
|
|
for c in idempotency_key
|
|
)
|
|
return os.path.join(get_journal_dir(journal_dir), f"{safe_key}.json")
|
|
|
|
|
|
def load_phase_journal(
|
|
idempotency_key: str, journal_dir: str | None = None
|
|
) -> dict[str, Any] | None:
|
|
"""Load a durable phase journal if it exists."""
|
|
path = _journal_file_path(idempotency_key, journal_dir=journal_dir)
|
|
if not os.path.isfile(path):
|
|
return None
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def save_phase_journal(
|
|
journal: dict[str, Any], journal_dir: str | None = None
|
|
) -> None:
|
|
"""Persist a durable phase journal with write-through file sync."""
|
|
key = journal["idempotency_key"]
|
|
path = _journal_file_path(key, journal_dir=journal_dir)
|
|
tmp_path = f"{path}.tmp.{os.getpid()}"
|
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
json.dump(journal, f, indent=2, sort_keys=True)
|
|
os.replace(tmp_path, path)
|
|
|
|
|
|
def derive_default_idempotency_key(
|
|
remote: str,
|
|
org: str | None,
|
|
repo: str | None,
|
|
issue_number: int,
|
|
assignment_id: str | None = None,
|
|
lease_id: str | None = None,
|
|
) -> str:
|
|
parts = [
|
|
"bootstrap",
|
|
(remote or "prgs").strip(),
|
|
(org or "Scaled-Tech-Consulting").strip(),
|
|
(repo or "Gitea-Tools").strip(),
|
|
f"issue-{issue_number}",
|
|
]
|
|
if assignment_id:
|
|
parts.append(assignment_id.strip())
|
|
if lease_id:
|
|
parts.append(lease_id.strip())
|
|
return ":".join(parts)
|
|
|
|
|
|
def _verify_assignment_and_lease_ids(
|
|
*,
|
|
assignment_id: str | None,
|
|
lease_id: str | None,
|
|
issue_number: int,
|
|
owner_session: str,
|
|
remote: str,
|
|
org: str | None,
|
|
repo: str | None,
|
|
db: control_plane_db.ControlPlaneDB | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Fail closed when caller-supplied assignment/lease IDs are unverified (#531 F4).
|
|
|
|
Both IDs are optional together. When either is supplied, both must be
|
|
present and must resolve to the same live control-plane lease/assignment
|
|
bound to this issue and owner session. Fabricated identifiers never pass.
|
|
"""
|
|
asn = (assignment_id or "").strip()
|
|
lid = (lease_id or "").strip()
|
|
if not asn and not lid:
|
|
return None
|
|
if not asn or not lid:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "incomplete_assignment_lease_ids",
|
|
"message": (
|
|
"assignment_id and lease_id must be supplied together when "
|
|
"either is provided (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Pass both identifiers from allocate_next_work / control-plane "
|
|
"assignment proof, or omit both."
|
|
),
|
|
}
|
|
|
|
try:
|
|
store = db if db is not None else control_plane_db.ControlPlaneDB()
|
|
state = store.get_lease_workflow_state(lid)
|
|
except Exception as exc:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "assignment_lease_lookup_failed",
|
|
"message": f"Could not verify assignment/lease against control plane: {exc}",
|
|
"exact_next_action": (
|
|
"Ensure the control-plane DB is available and retry with live IDs."
|
|
),
|
|
}
|
|
if not state or not state.get("lease"):
|
|
return {
|
|
"success": False,
|
|
"reason_code": "unknown_lease_id",
|
|
"message": f"lease_id '{lid}' is not present in the control plane (fail closed).",
|
|
"exact_next_action": "Pass a live lease_id from control-plane assignment.",
|
|
}
|
|
lease = state["lease"]
|
|
assignment = state.get("assignment") or {}
|
|
work = state.get("work_item") or {}
|
|
recorded_asn = str(assignment.get("assignment_id") or "").strip()
|
|
if recorded_asn and recorded_asn != asn:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "assignment_lease_mismatch",
|
|
"message": (
|
|
f"assignment_id '{asn}' does not match lease '{lid}' "
|
|
f"(recorded assignment '{recorded_asn}') (fail closed)."
|
|
),
|
|
"exact_next_action": "Re-read allocate_next_work proof and pass matching IDs.",
|
|
}
|
|
if not recorded_asn:
|
|
# Some lease rows may not yet have an assignment join; still require
|
|
# the lease itself to exist and bind to the claimed session/issue.
|
|
pass
|
|
# #943 review 622 B2: a lease that is no longer live confers no ownership.
|
|
# Existence alone previously satisfied this gate, so a released or expired
|
|
# lease could still authorize a bootstrap for a claim its session had given
|
|
# up. Checked before the session comparison so the reason names the real
|
|
# problem rather than reporting a mismatch.
|
|
from datetime import datetime, timezone
|
|
|
|
lease_status = str(lease.get("status") or "").strip().lower()
|
|
if lease_status and lease_status != "active":
|
|
return {
|
|
"success": False,
|
|
"reason_code": "lease_not_live",
|
|
"message": (
|
|
f"lease_id '{lid}' is '{lease_status}', not active; a lease that "
|
|
"is not live confers no ownership (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Re-allocate the work item and pass the live assignment/lease pair."
|
|
),
|
|
}
|
|
expires_raw = str(lease.get("expires_at") or "").strip()
|
|
if expires_raw:
|
|
try:
|
|
expires_at = datetime.fromisoformat(expires_raw.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "lease_not_live",
|
|
"message": (
|
|
f"lease_id '{lid}' records an unparseable expiry "
|
|
f"'{expires_raw}' (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Re-allocate the work item and pass the live "
|
|
"assignment/lease pair."
|
|
),
|
|
}
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
if expires_at <= datetime.now(timezone.utc):
|
|
return {
|
|
"success": False,
|
|
"reason_code": "lease_not_live",
|
|
"message": (
|
|
f"lease_id '{lid}' expired at {expires_raw}; an expired lease "
|
|
"confers no ownership (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Reclaim or re-allocate the lease, then retry with the live pair."
|
|
),
|
|
}
|
|
|
|
lease_session = str(lease.get("session_id") or "").strip()
|
|
if lease_session and lease_session != owner_session:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "lease_session_mismatch",
|
|
"message": (
|
|
f"lease_id '{lid}' is owned by session '{lease_session}', not "
|
|
f"'{owner_session}' (fail closed)."
|
|
),
|
|
"exact_next_action": "Use the session that holds the lease, or re-allocate.",
|
|
}
|
|
# Bind issue number when the work item records one.
|
|
work_number = work.get("number") or work.get("issue_number") or assignment.get("issue_number")
|
|
try:
|
|
if work_number is not None and int(work_number) != int(issue_number):
|
|
return {
|
|
"success": False,
|
|
"reason_code": "lease_issue_mismatch",
|
|
"message": (
|
|
f"lease_id '{lid}' is bound to issue #{work_number}, not "
|
|
f"#{issue_number} (fail closed)."
|
|
),
|
|
"exact_next_action": "Pass the lease issued for this issue number.",
|
|
}
|
|
except (TypeError, ValueError):
|
|
pass
|
|
_ = (remote, org, repo) # reserved for host-scoped DBs
|
|
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(
|
|
journal: dict[str, Any],
|
|
canonical_repo_root: str,
|
|
journal_dir: str | None = None,
|
|
*,
|
|
db: control_plane_db.ControlPlaneDB | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Execute compensating recovery for artifacts created by this transition only."""
|
|
artifacts = journal.get("artifacts_created") or {}
|
|
pending = journal.get("pending_creations") or {}
|
|
rolled_back: list[str] = []
|
|
worktree_path = journal.get("worktree_path")
|
|
branch_name = journal.get("branch_name")
|
|
|
|
# Roll back workflow lease/assignment when this transition bound one (#531 F5).
|
|
lease_id = str(journal.get("lease_id") or "").strip()
|
|
session_id = str(journal.get("owner_session") or "").strip()
|
|
if lease_id and session_id:
|
|
try:
|
|
store = db if db is not None else control_plane_db.ControlPlaneDB()
|
|
lease_lifecycle.release_lease(
|
|
store, lease_id=lease_id, session_id=session_id
|
|
)
|
|
rolled_back.append(f"lease:{lease_id}")
|
|
except Exception as exc:
|
|
rolled_back.append(f"lease_release_failed:{lease_id}:{type(exc).__name__}")
|
|
|
|
# Roll back issue lock if created
|
|
if artifacts.get("lock_created") or pending.get("lock"):
|
|
issue_num = journal.get("issue_number")
|
|
if issue_num and session_id:
|
|
try:
|
|
issue_lock_store.release_session_lock(
|
|
issue_number=issue_num,
|
|
session=session_id,
|
|
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}")
|
|
except Exception as exc:
|
|
# #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
|
|
|
|
worktree_created = (
|
|
artifacts.get("worktree_registered")
|
|
or artifacts.get("worktree_dir_created")
|
|
or (pending.get("worktree_path") == worktree_path and worktree_path)
|
|
)
|
|
|
|
if worktree_created and worktree_path:
|
|
if os.path.exists(worktree_path):
|
|
# Re-verify cleanliness before destructive removal (F-4)
|
|
porc_res = subprocess.run(
|
|
["git", "-C", worktree_path, "status", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
is_dirty = porc_res.returncode == 0 and bool(porc_res.stdout.strip())
|
|
if is_dirty:
|
|
rolled_back.append(f"worktree_path_preserved_dirty:{worktree_path}")
|
|
else:
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
canonical_repo_root,
|
|
"worktree",
|
|
"remove",
|
|
"--force",
|
|
worktree_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
pass
|
|
if os.path.exists(worktree_path):
|
|
shutil.rmtree(worktree_path, ignore_errors=True)
|
|
try:
|
|
subprocess.run(
|
|
["git", "-C", canonical_repo_root, "worktree", "prune"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
pass
|
|
rolled_back.append(f"worktree_path:{worktree_path}")
|
|
else:
|
|
rolled_back.append(f"worktree_path:{worktree_path}")
|
|
|
|
branch_created = (
|
|
artifacts.get("branch_created")
|
|
or (pending.get("branch_name") == branch_name and branch_name)
|
|
)
|
|
|
|
if branch_created and branch_name:
|
|
try:
|
|
res = subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
canonical_repo_root,
|
|
"rev-parse",
|
|
"--verify",
|
|
branch_name,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if res.returncode == 0:
|
|
# Check for author commits on branch before branch deletion (F-4)
|
|
resolved_base = journal.get("resolved_base_sha") or "master"
|
|
rev_list_res = subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
canonical_repo_root,
|
|
"rev-list",
|
|
f"{resolved_base}..{branch_name}",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
has_commits = rev_list_res.returncode == 0 and bool(rev_list_res.stdout.strip())
|
|
if has_commits:
|
|
rolled_back.append(f"branch_preserved_commits:{branch_name}")
|
|
else:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
canonical_repo_root,
|
|
"branch",
|
|
"-D",
|
|
branch_name,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
rolled_back.append(f"branch:{branch_name}")
|
|
except Exception:
|
|
pass
|
|
|
|
recovery_info = {
|
|
"executed": True,
|
|
"rolled_back": rolled_back,
|
|
"reason": journal.get("failure_reason"),
|
|
}
|
|
journal["compensating_recovery"] = recovery_info
|
|
journal["current_phase"] = PHASE_COMPENSATING_RECOVERY
|
|
save_phase_journal(journal, journal_dir=journal_dir)
|
|
return recovery_info
|
|
|
|
|
|
def _normalize_sha(value: str | None) -> str | None:
|
|
"""Normalize a Git object id for comparison, or ``None`` when unknown."""
|
|
normalized = (value or "").strip().lower()
|
|
return normalized or None
|
|
|
|
|
|
def _author_bootstrap_assessment(
|
|
*,
|
|
not_applicable: bool,
|
|
allowed: bool,
|
|
block: bool,
|
|
reasons: list[str],
|
|
workspace: str,
|
|
root: str,
|
|
branch: str | None,
|
|
dirty: list[str],
|
|
under_branches: bool,
|
|
bootstrap_path: str | None = None,
|
|
local_head_sha: str | None = None,
|
|
remote_master_sha: str | None = None,
|
|
exact_next_action: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Structured author-bootstrap assessment consumable by bootstrap_permits (#892).
|
|
|
|
Field shape mirrors :func:`create_issue_bootstrap._result` so the shared
|
|
``bootstrap_permits_control_checkout`` predicate can prove control-checkout
|
|
eligibility for ``gitea_bootstrap_author_issue_worktree`` the same way it
|
|
does for ``create_issue``. Allowed control assessments must use empty
|
|
``reasons`` — narrative belongs in other fields, not the refusal list.
|
|
"""
|
|
local_tip = _normalize_sha(local_head_sha)
|
|
remote_tip = _normalize_sha(remote_master_sha)
|
|
base_tips_verified = bool(local_tip and remote_tip and local_tip == remote_tip)
|
|
return {
|
|
"not_applicable": not_applicable,
|
|
"allowed": allowed,
|
|
"block": block,
|
|
"proven": bool(allowed and not block and not not_applicable),
|
|
"reasons": list(reasons),
|
|
"workspace_path": workspace,
|
|
"canonical_repo_root": root,
|
|
"current_branch": branch,
|
|
"dirty_files": list(dirty),
|
|
"under_branches": under_branches,
|
|
"exact_next_action": exact_next_action,
|
|
"bootstrap_path": bootstrap_path,
|
|
"task_scope": "author_issue_bootstrap",
|
|
"local_head_sha": local_tip,
|
|
"remote_master_sha": remote_tip,
|
|
"base_tips_verified": base_tips_verified,
|
|
}
|
|
|
|
|
|
EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP = (
|
|
"Restore the canonical control checkout to a clean accepted base branch "
|
|
"(master/main/dev) that matches live master, with no tracked local edits. "
|
|
"Re-resolve bootstrap_author_issue_worktree, then re-run "
|
|
"gitea_bootstrap_author_issue_worktree from that clean control checkout. "
|
|
"Do not use shell git worktree add as the primary path once bootstrap is healthy."
|
|
)
|
|
|
|
|
|
def assess_author_issue_bootstrap(
|
|
*,
|
|
workspace_path: str,
|
|
canonical_repo_root: str,
|
|
current_branch: str | None = None,
|
|
head_sha: str | None = None,
|
|
porcelain_status: str = "",
|
|
remote_master_sha: str | None = None,
|
|
remote_master_sha_error: str | None = None,
|
|
task: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root.
|
|
|
|
#892: control-checkout successes emit the full field set required by
|
|
``create_issue_bootstrap.bootstrap_permits_control_checkout`` (empty reasons,
|
|
task_scope, base tip proof, binding paths) so the #274/#604 guards can
|
|
waive control-checkout for this one sanctioned bootstrap task.
|
|
"""
|
|
root = os.path.realpath(canonical_repo_root or "")
|
|
workspace = os.path.realpath(workspace_path or root or ".")
|
|
branch = (current_branch or "").strip()
|
|
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
|
under_branches = (
|
|
author_mutation_worktree.is_path_under_branches(workspace, root)
|
|
if root
|
|
else False
|
|
)
|
|
local_tip = _normalize_sha(head_sha)
|
|
remote_tip = _normalize_sha(remote_master_sha)
|
|
|
|
if not is_author_issue_bootstrap_task(task):
|
|
return _author_bootstrap_assessment(
|
|
not_applicable=True,
|
|
allowed=False,
|
|
block=False,
|
|
reasons=["task is not author_issue_bootstrap"],
|
|
workspace=workspace,
|
|
root=root,
|
|
branch=branch or None,
|
|
dirty=dirty,
|
|
under_branches=under_branches,
|
|
)
|
|
|
|
# Already under branches/: ordinary #274 path applies; not a control waiver.
|
|
if under_branches:
|
|
return _author_bootstrap_assessment(
|
|
not_applicable=True,
|
|
allowed=False,
|
|
block=False,
|
|
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
|
workspace=workspace,
|
|
root=root,
|
|
branch=branch or None,
|
|
dirty=dirty,
|
|
under_branches=True,
|
|
bootstrap_path="existing_branches_worktree",
|
|
local_head_sha=local_tip,
|
|
remote_master_sha=remote_tip,
|
|
)
|
|
|
|
reasons: list[str] = []
|
|
if not root or workspace != root:
|
|
reasons.append(
|
|
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
|
)
|
|
if not branch:
|
|
reasons.append(
|
|
"control checkout is detached HEAD; expected an accepted base branch "
|
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
|
)
|
|
elif branch not in author_mutation_worktree.BASE_BRANCHES:
|
|
reasons.append(
|
|
f"control checkout branch '{branch}' is not an accepted base branch "
|
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
|
)
|
|
if dirty:
|
|
reasons.append(
|
|
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
|
)
|
|
|
|
# Fail closed on missing tip proof (same bar as create_issue bootstrap #757).
|
|
if not local_tip:
|
|
reasons.append(
|
|
"control checkout HEAD SHA is unknown; base equivalence to live "
|
|
"master cannot be proven (fail closed)"
|
|
)
|
|
resolver_error = (remote_master_sha_error or "").strip() or None
|
|
if resolver_error:
|
|
reasons.append(
|
|
f"live master tip could not be resolved ({resolver_error}); "
|
|
"base equivalence cannot be proven (fail closed)"
|
|
)
|
|
elif not remote_tip:
|
|
reasons.append(
|
|
"live master tip is unknown; base equivalence cannot be proven "
|
|
"(fail closed)"
|
|
)
|
|
elif local_tip and remote_tip and local_tip != remote_tip:
|
|
reasons.append(
|
|
f"control checkout HEAD ({local_tip[:12]}) != live master tip "
|
|
f"({remote_tip[:12]})"
|
|
)
|
|
|
|
if reasons:
|
|
return _author_bootstrap_assessment(
|
|
not_applicable=False,
|
|
allowed=False,
|
|
block=True,
|
|
reasons=reasons,
|
|
workspace=workspace,
|
|
root=root,
|
|
branch=branch or None,
|
|
dirty=dirty,
|
|
under_branches=False,
|
|
local_head_sha=local_tip,
|
|
remote_master_sha=remote_tip,
|
|
exact_next_action=EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP,
|
|
)
|
|
|
|
# Allowed: empty reasons so bootstrap_permits_control_checkout can pass.
|
|
return _author_bootstrap_assessment(
|
|
not_applicable=False,
|
|
allowed=True,
|
|
block=False,
|
|
reasons=[],
|
|
workspace=workspace,
|
|
root=root,
|
|
branch=branch or None,
|
|
dirty=dirty,
|
|
under_branches=False,
|
|
bootstrap_path="clean_canonical_control_checkout",
|
|
local_head_sha=local_tip,
|
|
remote_master_sha=remote_tip,
|
|
exact_next_action=(
|
|
"Call gitea_bootstrap_author_issue_worktree with the allocated "
|
|
"issue/lease pins; it will create the branches/ worktree and lock."
|
|
),
|
|
)
|
|
|
|
|
|
import fcntl
|
|
import stat
|
|
|
|
|
|
class BootstrapTransitionLock:
|
|
"""Inter-process file lock scoped to the transition identity / idempotency key."""
|
|
|
|
def __init__(self, idempotency_key: str, journal_dir: str | None = None):
|
|
safe_key = "".join(
|
|
c if c.isalnum() or c in ("-", "_", ".") else "_"
|
|
for c in idempotency_key
|
|
)
|
|
lock_dir = os.path.realpath(get_journal_dir(journal_dir))
|
|
if not os.path.isdir(lock_dir):
|
|
raise RuntimeError(f"Lock directory '{lock_dir}' does not exist or is not a directory")
|
|
|
|
raw_lock_path = os.path.abspath(os.path.join(lock_dir, f"{safe_key}.lock"))
|
|
try:
|
|
common = os.path.commonpath([lock_dir, os.path.dirname(raw_lock_path)])
|
|
except Exception:
|
|
common = None
|
|
if common != lock_dir:
|
|
raise RuntimeError(f"Lock path '{raw_lock_path}' escapes canonical lock directory '{lock_dir}'")
|
|
|
|
self.lock_path = raw_lock_path
|
|
self.fd = None
|
|
|
|
def __enter__(self):
|
|
if os.path.islink(self.lock_path):
|
|
raise RuntimeError(f"Refusing lock acquisition: lock path '{self.lock_path}' is a symlink")
|
|
|
|
flags = os.O_RDWR | os.O_CREAT
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
if hasattr(os, "O_CLOEXEC"):
|
|
flags |= os.O_CLOEXEC
|
|
|
|
try:
|
|
fd = os.open(self.lock_path, flags, 0o600)
|
|
except OSError as exc:
|
|
raise RuntimeError(f"Failed to open lock file safely '{self.lock_path}': {exc}") from exc
|
|
|
|
st = os.fstat(fd)
|
|
if not stat.S_ISREG(st.st_mode):
|
|
os.close(fd)
|
|
raise RuntimeError(f"Lock target '{self.lock_path}' is not a regular file")
|
|
|
|
self.fd = fd
|
|
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
if self.fd is not None:
|
|
try:
|
|
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
os.close(self.fd)
|
|
except Exception:
|
|
pass
|
|
self.fd = None
|
|
|
|
|
|
def bootstrap_author_issue_worktree(
|
|
*,
|
|
issue_number: int,
|
|
canonical_repo_root: str,
|
|
assignment_id: str | None = None,
|
|
lease_id: str | None = None,
|
|
expected_base_sha: str | None = None,
|
|
branch_name: str | None = None,
|
|
worktree_path: str | None = None,
|
|
idempotency_key: str | None = None,
|
|
remote: str = "prgs",
|
|
host: str | None = None,
|
|
org: str | None = "Scaled-Tech-Consulting",
|
|
repo: str | None = "Gitea-Tools",
|
|
active_identity: str | None = "jcwalker3",
|
|
active_profile: str | None = "prgs-author",
|
|
owner_session: str | None = None,
|
|
lock_dir: str | None = None,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Execute the sanctioned author issue worktree bootstrap transition."""
|
|
root = os.path.realpath(canonical_repo_root)
|
|
|
|
session = (owner_session or "").strip()
|
|
if not session:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "missing_owner_session",
|
|
"message": "Missing required owner_session parameter (fail closed). Session identifier cannot be fabricated or defaulted.",
|
|
"exact_next_action": (
|
|
"Pass explicit owner_session resolved from gitea_whoami or session context."
|
|
),
|
|
}
|
|
|
|
if active_identity is None or not str(active_identity).strip():
|
|
return {
|
|
"success": False,
|
|
"reason_code": "missing_active_identity",
|
|
"message": "Missing required active_identity parameter (fail closed). Identity cannot be fabricated or defaulted.",
|
|
"exact_next_action": "Pass explicit active_identity resolved from gitea_whoami.",
|
|
}
|
|
identity = str(active_identity).strip()
|
|
|
|
if active_profile is None or not str(active_profile).strip():
|
|
return {
|
|
"success": False,
|
|
"reason_code": "missing_active_profile",
|
|
"message": "Missing required active_profile parameter (fail closed). Profile cannot be fabricated or defaulted.",
|
|
"exact_next_action": "Pass explicit active_profile resolved from gitea_whoami.",
|
|
}
|
|
profile = str(active_profile).strip()
|
|
|
|
# Derive standard inputs
|
|
expected_pattern = f"issue-{issue_number}"
|
|
target_branch = (branch_name or "").strip()
|
|
if not target_branch:
|
|
target_branch = f"fix/issue-{issue_number}-native-mcp-bootstrap"
|
|
elif expected_pattern not in target_branch:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "invalid_branch_name",
|
|
"message": (
|
|
f"Branch name '{target_branch}' must contain issue pattern '{expected_pattern}'"
|
|
),
|
|
"exact_next_action": (
|
|
f"Supply a branch_name containing '{expected_pattern}', e.g., 'fix/issue-{issue_number}-...'"
|
|
),
|
|
}
|
|
|
|
worktree_name = target_branch.replace("/", "-")
|
|
target_worktree = (worktree_path or "").strip()
|
|
if not target_worktree:
|
|
target_worktree = os.path.join(root, "branches", worktree_name)
|
|
target_worktree = os.path.realpath(os.path.abspath(target_worktree))
|
|
|
|
key = (idempotency_key or "").strip()
|
|
if not key:
|
|
key = derive_default_idempotency_key(
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
issue_number=issue_number,
|
|
assignment_id=assignment_id,
|
|
lease_id=lease_id,
|
|
)
|
|
|
|
# Review #531 Finding 4: never embed unverified caller-supplied IDs.
|
|
id_block = _verify_assignment_and_lease_ids(
|
|
assignment_id=assignment_id,
|
|
lease_id=lease_id,
|
|
issue_number=issue_number,
|
|
owner_session=session,
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
)
|
|
if id_block is not None:
|
|
return id_block
|
|
|
|
# Acquire cross-process file lock scoped to the idempotency key / transition identity
|
|
with BootstrapTransitionLock(key, journal_dir=lock_dir):
|
|
# Idempotency check
|
|
existing = load_phase_journal(key, journal_dir=lock_dir)
|
|
if existing and existing.get("completed"):
|
|
if (
|
|
existing.get("issue_number") == issue_number
|
|
and existing.get("branch_name") == target_branch
|
|
and os.path.realpath(existing.get("worktree_path", ""))
|
|
== target_worktree
|
|
):
|
|
return {
|
|
"success": True,
|
|
"replayed": True,
|
|
"message": (
|
|
f"Idempotent replay: worktree for issue #{issue_number} already bootstrapped at {target_worktree}"
|
|
),
|
|
"issue_number": issue_number,
|
|
"branch_name": target_branch,
|
|
"worktree_path": target_worktree,
|
|
"base_sha": existing.get("resolved_base_sha"),
|
|
"lease_id": existing.get("lease_id"),
|
|
"assignment_id": existing.get("assignment_id"),
|
|
"idempotency_key": key,
|
|
"phase_journal": existing,
|
|
"exact_next_action": (
|
|
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') "
|
|
"and proceed with author implementation in the bootstrapped worktree."
|
|
),
|
|
}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "incompatible_idempotency_replay",
|
|
"message": (
|
|
f"Idempotency key '{key}' already exists with incompatible parameters "
|
|
f"(stored: {existing.get('branch_name')}, {existing.get('worktree_path')}; "
|
|
f"requested: {target_branch}, {target_worktree})"
|
|
),
|
|
"exact_next_action": (
|
|
"Supply a unique idempotency_key or pass compatible parameters."
|
|
),
|
|
}
|
|
|
|
# Initialize or resume Phase Journal
|
|
if existing:
|
|
journal = existing
|
|
artifacts = journal.setdefault("artifacts_created", {})
|
|
artifacts.setdefault("branch_created", False)
|
|
artifacts.setdefault("worktree_dir_created", False)
|
|
artifacts.setdefault("worktree_registered", False)
|
|
artifacts.setdefault("lock_created", False)
|
|
else:
|
|
journal = {
|
|
"idempotency_key": key,
|
|
"issue_number": issue_number,
|
|
"assignment_id": assignment_id,
|
|
"lease_id": lease_id,
|
|
"expected_base_sha": expected_base_sha,
|
|
"resolved_base_sha": None,
|
|
"branch_name": target_branch,
|
|
"worktree_path": target_worktree,
|
|
"active_identity": identity,
|
|
"active_profile": profile,
|
|
"owner_session": session,
|
|
"remote": remote,
|
|
"org": org,
|
|
"repo": repo,
|
|
"phases": {},
|
|
"artifacts_created": {
|
|
"branch_created": False,
|
|
"worktree_dir_created": False,
|
|
"worktree_registered": False,
|
|
"lock_created": False,
|
|
},
|
|
"current_phase": PHASE_1_REQUEST_ACCEPTED,
|
|
"completed": False,
|
|
}
|
|
|
|
# Fetch current live master SHA
|
|
try:
|
|
rev_res = subprocess.run(
|
|
["git", "-C", root, "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
live_master_sha = rev_res.stdout.strip()
|
|
except Exception as exc:
|
|
return {
|
|
"success": False,
|
|
"reason_code": "git_rev_parse_failed",
|
|
"message": f"Could not determine repository HEAD: {exc}",
|
|
"exact_next_action": "Verify repository git state and retry.",
|
|
}
|
|
|
|
# Phase 1: REQUEST_ACCEPTED & Concurrency Pin Check
|
|
if expected_base_sha:
|
|
exp_norm = expected_base_sha.strip().lower()
|
|
live_norm = live_master_sha.lower()
|
|
if exp_norm != live_norm:
|
|
journal["failure_reason"] = (
|
|
f"stale concurrency pin: expected {exp_norm[:12]} != live {live_norm[:12]}"
|
|
)
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "stale_concurrency_pin",
|
|
"message": (
|
|
f"Expected base SHA {exp_norm[:12]} does not match live master SHA {live_norm[:12]} (fail closed)."
|
|
),
|
|
"expected_base_sha": expected_base_sha,
|
|
"live_master_sha": live_master_sha,
|
|
"exact_next_action": (
|
|
"Re-evaluate assignment against current live master SHA and retry with updated expected_base_sha."
|
|
),
|
|
}
|
|
|
|
journal["resolved_base_sha"] = live_master_sha
|
|
journal["phases"][PHASE_1_REQUEST_ACCEPTED] = {
|
|
"status": "completed",
|
|
"live_master_sha": live_master_sha,
|
|
"expected_base_sha": expected_base_sha,
|
|
}
|
|
journal["current_phase"] = PHASE_2_BRANCH_CONFIRMED
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
if dry_run:
|
|
return {
|
|
"success": True,
|
|
"dry_run": True,
|
|
"message": f"Dry-run: validated bootstrap intent for issue #{issue_number}",
|
|
"issue_number": issue_number,
|
|
"branch_name": target_branch,
|
|
"worktree_path": target_worktree,
|
|
"base_sha": live_master_sha,
|
|
"phase_journal": journal,
|
|
"exact_next_action": "Run without dry_run=True to execute bootstrap.",
|
|
}
|
|
|
|
# Phase 2: BRANCH_CONFIRMED
|
|
was_branch_created_previously = journal["artifacts_created"].get("branch_created", False)
|
|
pending_branch = (journal.get("pending_creations") or {}).get("branch_name")
|
|
|
|
branch_check = subprocess.run(
|
|
["git", "-C", root, "rev-parse", "--verify", target_branch],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if branch_check.returncode == 0:
|
|
branch_head = branch_check.stdout.strip()
|
|
# Verify branch head descends from base
|
|
anc_check = subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
root,
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
live_master_sha,
|
|
branch_head,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
# F-8 / review #531 Finding 3: require the existing branch head to
|
|
# *contain* live master (is-ancestor) or equal it. Sharing any
|
|
# historical merge-base is not enough — that would accept stale or
|
|
# diverged branches that merely share history with master.
|
|
if anc_check.returncode != 0 and branch_head.lower() != live_master_sha.lower():
|
|
journal["failure_reason"] = (
|
|
f"existing branch '{target_branch}' HEAD ({branch_head[:12]}) "
|
|
f"does not contain live master ({live_master_sha[:12]})"
|
|
)
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "incompatible_existing_branch",
|
|
"message": (
|
|
f"Existing branch '{target_branch}' HEAD ({branch_head[:12]}) "
|
|
f"does not contain live master ({live_master_sha[:12]}). "
|
|
"Stale or diverged branches are refused (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Update the branch by merging current master (no rebase/"
|
|
"force-push), or choose a branch that already contains master."
|
|
),
|
|
}
|
|
# Preserve creation provenance monotonically across interruption and replay
|
|
if was_branch_created_previously or pending_branch == target_branch:
|
|
journal["artifacts_created"]["branch_created"] = True
|
|
else:
|
|
journal["artifacts_created"]["branch_created"] = False
|
|
else:
|
|
# Persist creation intent/provenance to disk BEFORE executing external mutation
|
|
journal.setdefault("pending_creations", {})["branch_name"] = target_branch
|
|
journal["artifacts_created"]["branch_created"] = True
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
# Create branch
|
|
create_res = subprocess.run(
|
|
["git", "-C", root, "branch", target_branch, live_master_sha],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if create_res.returncode != 0:
|
|
journal["artifacts_created"]["branch_created"] = False
|
|
journal.get("pending_creations", {}).pop("branch_name", None)
|
|
journal["failure_reason"] = (
|
|
f"failed to create git branch '{target_branch}': {create_res.stderr.strip()}"
|
|
)
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "branch_creation_failed",
|
|
"message": f"Failed to create git branch '{target_branch}': {create_res.stderr.strip()}",
|
|
"exact_next_action": "Verify branch availability and retry.",
|
|
}
|
|
|
|
journal["phases"][PHASE_2_BRANCH_CONFIRMED] = {
|
|
"status": "completed",
|
|
"branch_name": target_branch,
|
|
"created": journal["artifacts_created"]["branch_created"],
|
|
}
|
|
journal["current_phase"] = PHASE_3_PATH_RESERVED
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
# Phase 3: PATH_RESERVED & Phase 4: WORKTREE_CONFIRMED
|
|
if not author_mutation_worktree.is_path_under_branches(
|
|
target_worktree, root
|
|
):
|
|
journal["failure_reason"] = (
|
|
f"target_worktree '{target_worktree}' is outside canonical branches/ root"
|
|
)
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "path_outside_canonical_branches_root",
|
|
"message": (
|
|
f"Worktree path '{target_worktree}' is outside canonical branches/ root (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Provide a worktree_path inside canonical branches/ root, e.g., 'branches/issue-...'"
|
|
),
|
|
}
|
|
|
|
was_dir_created_previously = journal["artifacts_created"].get("worktree_dir_created", False)
|
|
was_registered_previously = journal["artifacts_created"].get("worktree_registered", False)
|
|
pending_wt = (journal.get("pending_creations") or {}).get("worktree_path")
|
|
|
|
dir_exists = os.path.exists(target_worktree)
|
|
if dir_exists:
|
|
# Check porcelain directly
|
|
porc_res = subprocess.run(
|
|
["git", "-C", target_worktree, "status", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
dirty = (
|
|
parse_dirty_tracked_files(porc_res.stdout)
|
|
if porc_res.returncode == 0
|
|
else []
|
|
)
|
|
if dirty or (porc_res.returncode == 0 and porc_res.stdout.strip()):
|
|
journal["failure_reason"] = (
|
|
f"target worktree '{target_worktree}' contains dirty tracked/untracked files"
|
|
)
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "preexisting_dirty_worktree",
|
|
"message": (
|
|
f"Preexisting worktree '{target_worktree}' has dirty tracked/untracked files (fail closed)."
|
|
),
|
|
"exact_next_action": (
|
|
"Clean or stash the pre-existing worktree files before bootstrapping."
|
|
),
|
|
}
|
|
|
|
# Check registered branch
|
|
wt_state = issue_lock_worktree.read_worktree_git_state(target_worktree)
|
|
wt_branch = (wt_state.get("current_branch") or "").strip()
|
|
if wt_branch and wt_branch != target_branch:
|
|
journal["failure_reason"] = (
|
|
f"existing worktree '{target_worktree}' is on branch '{wt_branch}' != expected '{target_branch}'"
|
|
)
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "incompatible_existing_directory",
|
|
"message": (
|
|
f"Existing worktree '{target_worktree}' is registered to branch '{wt_branch}' instead of '{target_branch}'."
|
|
),
|
|
"exact_next_action": (
|
|
"Inspect or remove the pre-existing worktree folder before bootstrapping."
|
|
),
|
|
}
|
|
if was_dir_created_previously or pending_wt == target_worktree:
|
|
journal["artifacts_created"]["worktree_dir_created"] = True
|
|
journal["artifacts_created"]["worktree_registered"] = True
|
|
else:
|
|
journal["artifacts_created"]["worktree_dir_created"] = False
|
|
journal["artifacts_created"]["worktree_registered"] = False
|
|
else:
|
|
# Persist creation intent/provenance to disk BEFORE executing external worktree add mutation
|
|
journal.setdefault("pending_creations", {})["worktree_path"] = target_worktree
|
|
journal["artifacts_created"]["worktree_dir_created"] = True
|
|
journal["artifacts_created"]["worktree_registered"] = True
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
wt_add_res = subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
root,
|
|
"worktree",
|
|
"add",
|
|
target_worktree,
|
|
target_branch,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if wt_add_res.returncode != 0:
|
|
journal["artifacts_created"]["worktree_dir_created"] = False
|
|
journal["artifacts_created"]["worktree_registered"] = False
|
|
journal.get("pending_creations", {}).pop("worktree_path", None)
|
|
journal["failure_reason"] = (
|
|
f"git worktree add failed: {wt_add_res.stderr.strip()}"
|
|
)
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "worktree_add_failed",
|
|
"message": f"Failed to execute git worktree add: {wt_add_res.stderr.strip()}",
|
|
"exact_next_action": "Verify git worktree capabilities and retry.",
|
|
}
|
|
|
|
journal["phases"][PHASE_3_PATH_RESERVED] = {
|
|
"status": "completed",
|
|
"worktree_path": target_worktree,
|
|
"preexisting_dir": dir_exists,
|
|
}
|
|
journal["current_phase"] = PHASE_4_WORKTREE_CONFIRMED
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
# Phase 5: REGISTRATION_VERIFIED
|
|
wt_list_res = subprocess.run(
|
|
["git", "-C", root, "worktree", "list", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
norm_target = os.path.realpath(target_worktree)
|
|
found_registration = False
|
|
if wt_list_res.returncode == 0:
|
|
for block in wt_list_res.stdout.split("\n\n"):
|
|
lines = block.strip().splitlines()
|
|
worktree_line = next(
|
|
(l[9:].strip() for l in lines if l.startswith("worktree ")),
|
|
None,
|
|
)
|
|
if worktree_line and os.path.realpath(worktree_line) == norm_target:
|
|
found_registration = True
|
|
break
|
|
|
|
if not found_registration:
|
|
journal["failure_reason"] = (
|
|
f"worktree registration for '{target_worktree}' not found in git worktree list"
|
|
)
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "worktree_registration_verification_failed",
|
|
"message": f"Worktree '{target_worktree}' registration verification failed.",
|
|
"exact_next_action": "Check git worktree list integrity and retry.",
|
|
}
|
|
|
|
journal["phases"][PHASE_4_WORKTREE_CONFIRMED] = {
|
|
"status": "completed",
|
|
"worktree_path": target_worktree,
|
|
}
|
|
journal["phases"][PHASE_5_REGISTRATION_VERIFIED] = {
|
|
"status": "completed",
|
|
"registered": True,
|
|
}
|
|
journal["current_phase"] = PHASE_6_STATE_ESTABLISHED
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
|
|
#
|
|
# #953: this used to hand-build a thinner record — claimant at the top
|
|
# level, no work_lease, no lock_provenance, no expiry — which every
|
|
# downstream reader then refused. It now builds through the one shared
|
|
# canonical contract, so the lock bootstrap writes is the same lock
|
|
# gitea_lock_issue writes.
|
|
from datetime import datetime, timezone
|
|
try:
|
|
lock_data = author_lock_contract.build_canonical_issue_lock(
|
|
issue_number=issue_number,
|
|
branch_name=target_branch,
|
|
worktree_path=target_worktree,
|
|
remote=remote,
|
|
org=org or "Scaled-Tech-Consulting",
|
|
repo=repo or "Gitea-Tools",
|
|
identity=identity,
|
|
profile=profile,
|
|
tool="gitea_bootstrap_author_issue_worktree",
|
|
source=author_lock_contract.SOURCE_BOOTSTRAP,
|
|
owner_session=session,
|
|
assignment_id=assignment_id,
|
|
lease_id=lease_id,
|
|
expected_base_sha=live_master_sha,
|
|
)
|
|
lock_data["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
journal.setdefault("pending_creations", {})["lock"] = True
|
|
journal["artifacts_created"]["lock_created"] = True
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
lock_res = issue_lock_store.bind_session_lock(lock_data, lock_dir=lock_dir)
|
|
except Exception as exc:
|
|
journal["artifacts_created"]["lock_created"] = False
|
|
journal.get("pending_creations", {}).pop("lock", None)
|
|
journal["failure_reason"] = f"issue lock binding failed: {exc}"
|
|
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
|
return {
|
|
"success": False,
|
|
"reason_code": "issue_lock_acquisition_failed",
|
|
"message": f"Could not bind canonical issue lock for issue #{issue_number}: {exc}",
|
|
"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] = {
|
|
"status": "completed",
|
|
"lock": lock_res,
|
|
"lock_contract": contract["contract"],
|
|
}
|
|
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
|
|
"status": "completed",
|
|
}
|
|
journal["current_phase"] = PHASE_7_TRANSITION_COMPLETED
|
|
journal["completed"] = True
|
|
save_phase_journal(journal, journal_dir=lock_dir)
|
|
|
|
return {
|
|
"success": True,
|
|
"replayed": False,
|
|
"message": (
|
|
f"Successfully bootstrapped author issue worktree for issue #{issue_number} "
|
|
f"at branch '{target_branch}' and worktree '{target_worktree}'."
|
|
),
|
|
"issue_number": issue_number,
|
|
"branch_name": target_branch,
|
|
"worktree_path": target_worktree,
|
|
"base_sha": live_master_sha,
|
|
"lease_id": lease_id,
|
|
"assignment_id": assignment_id,
|
|
"idempotency_key": key,
|
|
"lock_state": lock_res,
|
|
"lock_contract": contract,
|
|
# #953 AC6: the canonical ownership token for this claim. Never null
|
|
# on a successful bootstrap — it is the fencing token every
|
|
# subsequent heartbeat and renewal is checked against.
|
|
"task_session_id": contract["task_session_id"],
|
|
"implementation_allowed": True,
|
|
"phase_journal": journal,
|
|
# #953 AC5: executable under the state actually returned. The lock
|
|
# has been read back and verified canonical, so proceeding to
|
|
# implementation is genuinely the correct next step here — which is
|
|
# exactly what the old unconditional wording could not promise.
|
|
"exact_next_action": author_lock_contract.recommended_action(contract),
|
|
}
|