- Remove /branches/ string-split fallback in resolve_canonical_repo_root; recover roots via commonpath ancestry only (review #531 F2). - Refuse existing branches that do not contain live master; no weak merge-base acceptance (F3). - Verify caller-supplied assignment_id/lease_id against the control plane or fail closed (F4). - Compensating recovery releases bound workflow leases via lease_lifecycle (F5). - Regression tests for each finding.
This commit is contained in:
+152
-22
@@ -24,6 +24,7 @@ import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
import author_mutation_worktree
|
||||
import control_plane_db
|
||||
import issue_lock_store
|
||||
import issue_lock_worktree
|
||||
import lease_lifecycle
|
||||
@@ -123,10 +124,114 @@ def derive_default_idempotency_key(
|
||||
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
|
||||
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 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 {}
|
||||
@@ -135,10 +240,22 @@ def run_compensating_recovery(
|
||||
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")
|
||||
session_id = journal.get("owner_session")
|
||||
if issue_num and session_id:
|
||||
try:
|
||||
issue_lock_store.release_session_lock(
|
||||
@@ -510,6 +627,19 @@ def bootstrap_author_issue_worktree(
|
||||
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
|
||||
@@ -677,29 +807,29 @@ def bootstrap_author_issue_worktree(
|
||||
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():
|
||||
# F-8: Check if branch shares a common ancestor with live master
|
||||
mb_check = subprocess.run(
|
||||
["git", "-C", root, "merge-base", live_master_sha, branch_head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
journal["failure_reason"] = (
|
||||
f"existing branch '{target_branch}' HEAD ({branch_head[:12]}) "
|
||||
f"does not contain live master ({live_master_sha[:12]})"
|
||||
)
|
||||
if mb_check.returncode != 0 or not mb_check.stdout.strip():
|
||||
journal["failure_reason"] = (
|
||||
f"existing branch '{target_branch}' HEAD ({branch_head[:12]}) is incompatible with 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]}) is incompatible with live master ({live_master_sha[:12]})."
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Inspect or sync the existing branch with master before bootstrapping."
|
||||
),
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user