diff --git a/control_plane_db.py b/control_plane_db.py index e456bcc..0dd9e38 100644 --- a/control_plane_db.py +++ b/control_plane_db.py @@ -11,9 +11,9 @@ Implements the durable coordination store described by shared Postgres or single allocator daemon can replace the connection layer later without changing call sites. -This module is the **substrate** for #600 (allocator policy/tool) and #612 -(incident bridge). It does **not** implement ``gitea_allocate_next_work`` -routing policy or provider adapters. +This module is the **substrate** for #600 (allocator policy/tool), #601 +(first-class lease lifecycle), and #612 (incident bridge). It does **not** +implement ``gitea_allocate_next_work`` routing policy or provider adapters. """ from __future__ import annotations @@ -29,7 +29,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Iterator, Sequence -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 # Assignable work kinds only — raw monitoring incidents are never work items. WORK_KINDS = frozenset({"issue", "pr"}) @@ -301,6 +301,7 @@ class ControlPlaneDB: try: conn.executescript(_SCHEMA_SQL) self._migrate_incident_links_null_scope(conn) + self._migrate_lease_lifecycle_columns(conn) conn.execute( "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", ("schema_version", str(SCHEMA_VERSION)), @@ -604,6 +605,8 @@ class ControlPlaneDB: lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS, phase: str = "claimed", now: datetime | None = None, + worktree_path: str | None = None, + owner_pid: int | None = None, ) -> AssignmentResult: """Atomically create assignment + lease for one Gitea work item. @@ -746,6 +749,32 @@ class ControlPlaneDB: """, (lease_id, work_item_id, session_id, role, phase, expires, now_s), ) + # #601 optional lifecycle columns (present after schema v3 migration) + try: + lcols = { + r[1] + for r in conn.execute("PRAGMA table_info(leases)").fetchall() + } + if "worktree_path" in lcols and worktree_path: + conn.execute( + "UPDATE leases SET worktree_path = ? WHERE lease_id = ?", + (worktree_path, lease_id), + ) + if "owner_pid" in lcols: + conn.execute( + "UPDATE leases SET owner_pid = ? WHERE lease_id = ?", + ( + owner_pid if owner_pid is not None else os.getpid(), + lease_id, + ), + ) + if "expected_head_sha" in lcols and expected_head_sha: + conn.execute( + "UPDATE leases SET expected_head_sha = ? WHERE lease_id = ?", + (expected_head_sha, lease_id), + ) + except sqlite3.Error: + pass conn.execute( """ INSERT INTO assignments( @@ -876,32 +905,15 @@ class ControlPlaneDB: } def release_lease(self, lease_id: str, *, session_id: str) -> None: - with self._tx() as conn: + """Release a lease; unknown ids are no-ops for backward compatibility.""" + with self._tx(immediate=False) as conn: row = conn.execute( - "SELECT * FROM leases WHERE lease_id = ?", + "SELECT lease_id FROM leases WHERE lease_id = ?", (lease_id,), ).fetchone() if not row: return - if row["session_id"] != session_id: - raise ForeignLeaseError( - f"cannot release lease {lease_id} owned by {row['session_id']}" - ) - conn.execute( - "UPDATE leases SET status = 'released' WHERE lease_id = ?", - (lease_id,), - ) - conn.execute( - "UPDATE assignments SET status = 'released' WHERE lease_id = ?", - (lease_id,), - ) - conn.execute( - """ - INSERT INTO events(work_item_id, event_type, message, created_at) - VALUES (?, 'lease_released', ?, ?) - """, - (row["work_item_id"], f"session {session_id} released {lease_id}", _ts()), - ) + self.release_lease_recorded(lease_id, session_id=session_id) def require_valid_assignment( self, @@ -1177,3 +1189,563 @@ class ControlPlaneDB: (provider, base_url, p_org, p_project, str(provider_issue_id)), ).fetchone() return dict(row) if row else None + + + # ── lease lifecycle (#601) ──────────────────────────────────────────── + + _LEASE_LIFECYCLE_COLUMNS: tuple[tuple[str, str], ...] = ( + ("worktree_path", "TEXT"), + ("owner_pid", "INTEGER"), + ("expected_head_sha", "TEXT"), + ("adopted_from_session_id", "TEXT"), + ("adopted_by_session_id", "TEXT"), + ("provenance_json", "TEXT"), + ("abandon_proof_json", "TEXT"), + ) + + def _migrate_lease_lifecycle_columns(self, conn: sqlite3.Connection) -> None: + """Add provenance/worktree columns to leases for first-class lifecycle (#601).""" + cols = { + row[1] + for row in conn.execute("PRAGMA table_info(leases)").fetchall() + } + if not cols: + return + for name, decl in self._LEASE_LIFECYCLE_COLUMNS: + if name not in cols: + conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}") + + def _lease_columns(self, conn: sqlite3.Connection) -> set[str]: + return { + row[1] + for row in conn.execute("PRAGMA table_info(leases)").fetchall() + } + + def list_leases( + self, + *, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + role: str | None = None, + statuses: Sequence[str] | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """List leases joined with work_items as first-class workflow state.""" + clauses: list[str] = [] + params: list[Any] = [] + if remote: + clauses.append("w.remote = ?") + params.append(remote) + if org: + clauses.append("w.org = ?") + params.append(org) + if repo: + clauses.append("w.repo = ?") + params.append(repo) + if role: + clauses.append("l.role = ?") + params.append(role) + if statuses: + placeholders = ", ".join("?" for _ in statuses) + clauses.append(f"l.status IN ({placeholders})") + params.extend(statuses) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + sql = f""" + SELECT l.*, w.remote, w.org, w.repo, w.kind AS work_kind, + w.number AS work_number, w.state AS work_state, + w.current_head_sha AS work_head_sha, + s.pid AS session_pid, s.profile AS session_profile, + s.status AS session_status + FROM leases l + JOIN work_items w ON w.work_item_id = l.work_item_id + LEFT JOIN sessions s ON s.session_id = l.session_id + {where} + ORDER BY l.expires_at DESC + LIMIT ? + """ + params.append(max(1, int(limit))) + with self._tx(immediate=False) as conn: + rows = conn.execute(sql, params).fetchall() + return [dict(r) for r in rows] + + def get_lease_workflow_state(self, lease_id: str) -> dict[str, Any] | None: + """Return lease + assignment + work_item + session for one lease id.""" + with self._tx(immediate=False) as conn: + lease = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + if not lease: + return None + work = conn.execute( + "SELECT * FROM work_items WHERE work_item_id = ?", + (lease["work_item_id"],), + ).fetchone() + asn = conn.execute( + """ + SELECT * FROM assignments + WHERE lease_id = ? + ORDER BY created_at DESC LIMIT 1 + """, + (lease_id,), + ).fetchone() + sess = conn.execute( + "SELECT * FROM sessions WHERE session_id = ?", + (lease["session_id"],), + ).fetchone() + provenance = None + if "provenance_json" in lease.keys() and lease["provenance_json"]: + try: + provenance = json.loads(lease["provenance_json"]) + except (TypeError, json.JSONDecodeError): + provenance = {"raw": lease["provenance_json"]} + return { + "lease": dict(lease), + "work_item": dict(work) if work else None, + "assignment": dict(asn) if asn else None, + "session": dict(sess) if sess else None, + "provenance": provenance, + } + + def attach_lease_provenance( + self, lease_id: str, provenance: dict[str, Any] + ) -> None: + payload = json.dumps(provenance) + with self._tx() as conn: + cols = self._lease_columns(conn) + if "provenance_json" not in cols: + return + conn.execute( + "UPDATE leases SET provenance_json = ? WHERE lease_id = ?", + (payload, lease_id), + ) + if provenance.get("adopted_from_session_id") and "adopted_from_session_id" in cols: + conn.execute( + """ + UPDATE leases + SET adopted_from_session_id = ?, adopted_by_session_id = ? + WHERE lease_id = ? + """, + ( + provenance.get("adopted_from_session_id"), + provenance.get("adopted_by_session_id"), + lease_id, + ), + ) + if provenance.get("worktree_path") and "worktree_path" in cols: + conn.execute( + "UPDATE leases SET worktree_path = ? WHERE lease_id = ?", + (provenance.get("worktree_path"), lease_id), + ) + if provenance.get("expected_head_sha") and "expected_head_sha" in cols: + conn.execute( + "UPDATE leases SET expected_head_sha = ? WHERE lease_id = ?", + (provenance.get("expected_head_sha"), lease_id), + ) + + def release_lease_recorded( + self, lease_id: str, *, session_id: str + ) -> dict[str, Any]: + """Explicit release with audit event + provenance proof (#601).""" + now_s = _ts() + with self._tx() as conn: + row = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + if not row: + raise ControlPlaneError(f"unknown lease_id {lease_id}") + if row["session_id"] != session_id: + raise ForeignLeaseError( + f"cannot release lease {lease_id} owned by {row['session_id']}" + ) + if row["status"] not in ("active", "expired"): + # idempotent-ish for already released + if row["status"] == "released": + return { + "lease_id": lease_id, + "status": "released", + "session_id": session_id, + "released_at": now_s, + "idempotent": True, + } + conn.execute( + "UPDATE leases SET status = 'released' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + "UPDATE assignments SET status = 'released' WHERE lease_id = ?", + (lease_id,), + ) + proof = { + "lease_id": lease_id, + "status": "released", + "session_id": session_id, + "released_at": now_s, + "prior_status": row["status"], + "work_item_id": row["work_item_id"], + } + cols = self._lease_columns(conn) + if "provenance_json" in cols: + prior = {} + if row["provenance_json"]: + try: + prior = json.loads(row["provenance_json"]) + except (TypeError, json.JSONDecodeError): + prior = {} + prior["last_release"] = proof + conn.execute( + "UPDATE leases SET provenance_json = ? WHERE lease_id = ?", + (json.dumps(prior), lease_id), + ) + conn.execute( + """ + INSERT INTO events(work_item_id, event_type, message, created_at) + VALUES (?, 'lease_released', ?, ?) + """, + ( + row["work_item_id"], + f"session {session_id} released {lease_id}", + now_s, + ), + ) + return proof + + def force_expire_lease(self, lease_id: str, *, reason: str = "") -> None: + now_s = _ts() + with self._tx() as conn: + row = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + if not row: + raise ControlPlaneError(f"unknown lease_id {lease_id}") + conn.execute( + "UPDATE leases SET status = 'expired' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + "UPDATE assignments SET status = 'expired' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + """ + INSERT INTO events(work_item_id, event_type, message, created_at) + VALUES (?, 'lease_expired', ?, ?) + """, + ( + row["work_item_id"], + f"lease {lease_id} force-expired: {reason or 'unspecified'}", + now_s, + ), + ) + + def abandon_lease( + self, + *, + lease_id: str, + requester_session_id: str, + proof: dict[str, Any], + ) -> dict[str, Any]: + now_s = _ts() + with self._tx() as conn: + row = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + if not row: + raise ControlPlaneError(f"unknown lease_id {lease_id}") + conn.execute( + "UPDATE leases SET status = 'abandoned' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + "UPDATE assignments SET status = 'abandoned' WHERE lease_id = ?", + (lease_id,), + ) + cols = self._lease_columns(conn) + if "abandon_proof_json" in cols: + conn.execute( + "UPDATE leases SET abandon_proof_json = ? WHERE lease_id = ?", + (json.dumps(proof), lease_id), + ) + msg = ( + f"session {requester_session_id} abandoned lease {lease_id} " + f"(prior owner {row['session_id']})" + ) + conn.execute( + """ + INSERT INTO events(work_item_id, event_type, message, created_at) + VALUES (?, 'lease_abandoned', ?, ?) + """, + (row["work_item_id"], msg, now_s), + ) + return { + "lease_id": lease_id, + "status": "abandoned", + "prior_owner_session_id": row["session_id"], + "requester_session_id": requester_session_id, + "abandoned_at": now_s, + "proof": proof, + } + + def adopt_lease( + self, + *, + lease_id: str, + adopter_session_id: str, + role: str, + worktree_path: str | None = None, + expected_head_sha: str | None = None, + owner_pid: int | None = None, + provenance: dict[str, Any] | None = None, + lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS, + ) -> dict[str, Any]: + """Transfer or refresh a lease with provenance (#601). + + * Same owner + active → refresh (owner-resume). + * Expired/abandoned/released → create new assignment+lease with provenance. + * Active foreign → raise ForeignLeaseError (never silent steal). + """ + now = _utc_now() + now_s = _ts(now) + expires = _ts(now + timedelta(seconds=int(lease_ttl_seconds))) + provenance = provenance or {} + + with self._tx(immediate=True) as conn: + lease = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + if not lease: + raise ControlPlaneError(f"unknown lease_id {lease_id}") + work = conn.execute( + "SELECT * FROM work_items WHERE work_item_id = ?", + (lease["work_item_id"],), + ).fetchone() + if not work: + raise ControlPlaneError("lease has no work_item") + + # Expire by time if needed + exp = _parse_ts(lease["expires_at"]) + status = lease["status"] + if status == "active" and exp and exp <= now: + conn.execute( + "UPDATE leases SET status = 'expired' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + "UPDATE assignments SET status = 'expired' WHERE lease_id = ?", + (lease_id,), + ) + status = "expired" + + owner = lease["session_id"] + if status == "active" and owner != adopter_session_id: + raise ForeignLeaseError( + f"cannot adopt active foreign lease {lease_id} owned by {owner}" + ) + + # Ensure adopter session exists + sess = conn.execute( + "SELECT session_id FROM sessions WHERE session_id = ?", + (adopter_session_id,), + ).fetchone() + if not sess: + conn.execute( + """ + INSERT INTO sessions( + session_id, role, profile, namespace, pid, + started_at, last_heartbeat_at, status + ) VALUES (?, ?, NULL, NULL, ?, ?, ?, 'active') + """, + (adopter_session_id, role, owner_pid or os.getpid(), now_s, now_s), + ) + + cols = self._lease_columns(conn) + + if status == "active" and owner == adopter_session_id: + # Owner-resume refresh + conn.execute( + """ + UPDATE leases + SET heartbeat_at = ?, expires_at = ?, phase = ? + WHERE lease_id = ? + """, + (now_s, expires, "adopted", lease_id), + ) + if "worktree_path" in cols and worktree_path: + conn.execute( + "UPDATE leases SET worktree_path = ? WHERE lease_id = ?", + (worktree_path, lease_id), + ) + if "owner_pid" in cols and owner_pid is not None: + conn.execute( + "UPDATE leases SET owner_pid = ? WHERE lease_id = ?", + (owner_pid, lease_id), + ) + if "provenance_json" in cols: + prior = {} + if lease["provenance_json"]: + try: + prior = json.loads(lease["provenance_json"]) + except (TypeError, json.JSONDecodeError): + prior = {} + prior["last_adopt"] = provenance + conn.execute( + "UPDATE leases SET provenance_json = ? WHERE lease_id = ?", + (json.dumps(prior), lease_id), + ) + asn = conn.execute( + """ + SELECT * FROM assignments + WHERE lease_id = ? AND status = 'active' + ORDER BY created_at DESC LIMIT 1 + """, + (lease_id,), + ).fetchone() + lease2 = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", (lease_id,) + ).fetchone() + conn.execute( + """ + INSERT INTO events(work_item_id, event_type, message, created_at) + VALUES (?, 'lease_adopted', ?, ?) + """, + ( + lease["work_item_id"], + f"owner-resume adopt lease {lease_id} by {adopter_session_id}", + now_s, + ), + ) + return { + "outcome": "adopted_owner_resume", + "lease": dict(lease2) if lease2 else dict(lease), + "assignment": dict(asn) if asn else None, + "reasons": ["owner-resume: refreshed lease with provenance"], + } + + # Non-active: create new lease + assignment (transfer) + new_lease_id = f"lease-{uuid.uuid4().hex[:16]}" + new_asn_id = f"asn-{uuid.uuid4().hex[:16]}" + # Mark prior non-active if still active somehow + if status == "active": + conn.execute( + "UPDATE leases SET status = 'released' WHERE lease_id = ?", + (lease_id,), + ) + conn.execute( + "UPDATE assignments SET status = 'released' WHERE lease_id = ?", + (lease_id,), + ) + + # Prefer prior assignment allowed/forbidden + prior_asn = conn.execute( + """ + SELECT * FROM assignments WHERE lease_id = ? + ORDER BY created_at DESC LIMIT 1 + """, + (lease_id,), + ).fetchone() + allowed = ( + prior_asn["allowed_actions"] + if prior_asn + else json.dumps(["implement", "comment", "push", "create_pr"]) + ) + forbidden = ( + prior_asn["forbidden_actions"] + if prior_asn + else json.dumps( + ["approve", "merge", "request_changes", "self_select_without_assignment"] + ) + ) + head = expected_head_sha or ( + prior_asn["expected_head_sha"] if prior_asn else work["current_head_sha"] + ) + + # Dynamic insert for optional columns + base_cols = [ + "lease_id", + "work_item_id", + "session_id", + "role", + "phase", + "expires_at", + "heartbeat_at", + "status", + ] + base_vals: list[Any] = [ + new_lease_id, + lease["work_item_id"], + adopter_session_id, + role, + "adopted", + expires, + now_s, + "active", + ] + optional = { + "worktree_path": worktree_path, + "owner_pid": owner_pid, + "expected_head_sha": head, + "adopted_from_session_id": owner, + "adopted_by_session_id": adopter_session_id, + "provenance_json": json.dumps(provenance), + } + for col, val in optional.items(): + if col in cols and val is not None: + base_cols.append(col) + base_vals.append(val) + placeholders = ", ".join("?" for _ in base_cols) + conn.execute( + f"INSERT INTO leases({', '.join(base_cols)}) VALUES ({placeholders})", + base_vals, + ) + conn.execute( + """ + INSERT INTO assignments( + assignment_id, work_item_id, session_id, lease_id, + allowed_actions, forbidden_actions, expected_head_sha, + role, status, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?) + """, + ( + new_asn_id, + lease["work_item_id"], + adopter_session_id, + new_lease_id, + allowed if isinstance(allowed, str) else json.dumps(list(allowed)), + forbidden if isinstance(forbidden, str) else json.dumps(list(forbidden)), + head, + role, + now_s, + ), + ) + conn.execute( + """ + INSERT INTO events(work_item_id, event_type, message, created_at) + VALUES (?, 'lease_adopted', ?, ?) + """, + ( + lease["work_item_id"], + f"session {adopter_session_id} adopted from {owner} " + f"prior={lease_id} new={new_lease_id}", + now_s, + ), + ) + lease2 = conn.execute( + "SELECT * FROM leases WHERE lease_id = ?", (new_lease_id,) + ).fetchone() + asn2 = conn.execute( + "SELECT * FROM assignments WHERE assignment_id = ?", + (new_asn_id,), + ).fetchone() + return { + "outcome": "adopted_transfer", + "lease": dict(lease2) if lease2 else None, + "assignment": dict(asn2) if asn2 else None, + "reasons": [ + f"transferred lease ownership from {owner} to {adopter_session_id}" + ], + } diff --git a/docs/architecture/control-plane-db-substrate.md b/docs/architecture/control-plane-db-substrate.md index e6d0439..b82536d 100644 --- a/docs/architecture/control-plane-db-substrate.md +++ b/docs/architecture/control-plane-db-substrate.md @@ -51,6 +51,35 @@ Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work` - Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait). - Raw monitoring incidents are never candidates. + +## Lease lifecycle API (#601) + +Module: `lease_lifecycle.py` · MCP tools: `gitea_*_workflow_lease(s)` + +Active control-plane leases are **first-class workflow state**: + +| Tool | Purpose | +|------|---------| +| `gitea_list_workflow_leases` | List active (or all) leases for a repo | +| `gitea_inspect_workflow_lease` | Freshness + `safe_next_action` for one lease id | +| `gitea_adopt_workflow_lease` | Owner-resume or sanctioned reclaim with provenance | +| `gitea_release_workflow_lease` | Explicit owner release (audited) | +| `gitea_expire_workflow_leases` | Deterministic expire of past-`expires_at` leases | +| `gitea_abandon_workflow_lease` | Abandon with required proof | +| `gitea_reclaim_expired_workflow_lease` | Expire + re-assign with provenance | + +### Hard rules + +1. Control-plane DB is the coordination authority — file locks / comment-only leases are not authoritative alone. +2. Active foreign leases cannot be stolen; inspect returns `wait_foreign_active`. +3. Abandon requires `(dead_process OR missing_worktree)` and `no_live_mutation_risk`; foreign abandon also needs `operator_authorized` or full dead+missing+no_open_pr proof. +4. Adopt provenance always records `adopted_from_session_id`, `adopted_by_session_id`, work identity, optional head SHA, and worktree path. +5. Reviewer→merger comment handoff (`gitea_adopt_merger_pr_lease`) is unchanged and remains the Gitea-thread durability path. + +```bash +python3 -m pytest tests/test_lease_lifecycle.py tests/test_control_plane_db.py tests/test_allocator_service.py tests/test_merger_lease_adoption.py -q +``` + ## Incident bridge (#612) Module: `incident_bridge.py` · MCP tools: `gitea_observability_*` diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 1a46081..66bf5cf 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -837,6 +837,7 @@ import mcp_session_state # noqa: E402 import stale_review_decision_lock # noqa: E402 import allocator_service # noqa: E402 import control_plane_db # noqa: E402 +import lease_lifecycle # noqa: E402 import incident_bridge # noqa: E402 import agent_temp_artifacts import issue_lock_worktree # noqa: E402 @@ -11622,6 +11623,315 @@ def gitea_allocate_next_work( return result + + +# ── Control-plane lease lifecycle (#601) ────────────────────────────────────── + + +@mcp.tool() +def gitea_list_workflow_leases( + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + role: str | None = None, + include_non_active: bool = False, + limit: int = 100, +) -> dict: + """List control-plane leases as first-class workflow state (#601). + + Read-only. Returns active (default) or all leases from the #613 DB substrate. + File locks and comment-only leases are not listed as authoritative here. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "leases": [], + "permission_report": _permission_block_report("gitea.read"), + } + try: + h, o, r = _resolve(remote, host, org, repo) + except ValueError as exc: + return {"success": False, "reasons": [str(exc)], "leases": []} + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs, "leases": []} + result = lease_lifecycle.list_active_leases( + db, + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + role=role, + include_non_active=bool(include_non_active), + limit=int(limit), + ) + result["remote"] = remote + result["org"] = o + result["repo"] = r + return result + + +@mcp.tool() +def gitea_inspect_workflow_lease( + lease_id: str, + session_id: str | None = None, + worktree_path: str | None = None, + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Inspect one control-plane lease with safe_next_action (#601). + + Distinguishes owner-resume, wait-foreign, reclaim-expired, abandon-allowed, + and stale prompt ids. Control-plane DB is authoritative. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or "session" + sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}" + return lease_lifecycle.inspect_lease( + db, + lease_id, + caller_session_id=sid, + caller_worktree=worktree_path, + ) + + +@mcp.tool() +def gitea_adopt_workflow_lease( + lease_id: str, + session_id: str | None = None, + role: str | None = None, + worktree_path: str | None = None, + expected_head_sha: str | None = None, + operator_authorized: bool = False, + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Adopt a control-plane lease through the sanctioned path (#601). + + Same-owner resume refreshes provenance. Foreign active leases are refused. + Expired leases may be reclaimed; provenance records adopted_from/by. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or "session" + active_role = _profile_role_kind(profile) or "author" + sid = (session_id or "").strip() or ( + f"{profile_name}-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) + try: + return lease_lifecycle.adopt_lease( + db, + lease_id=lease_id, + adopter_session_id=sid, + role=(role or active_role).strip() or "author", + worktree_path=worktree_path, + expected_head_sha=expected_head_sha, + owner_pid=os.getpid(), + operator_authorized=bool(operator_authorized), + ) + except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc: + return { + "success": False, + "outcome": "blocked", + "reasons": [_redact(str(exc))], + "lease_id": lease_id, + "authoritative_source": "control_plane_db", + "file_lock_only": False, + "comment_lease_only": False, + } + + +@mcp.tool() +def gitea_release_workflow_lease( + lease_id: str, + session_id: str, + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Explicitly release a control-plane lease owned by *session_id* (#601). + + Recorded in the DB event log with release provenance. Foreign release fails closed. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + try: + return lease_lifecycle.release_lease( + db, lease_id=lease_id, session_id=session_id + ) + except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc: + return { + "success": False, + "outcome": "blocked", + "reasons": [_redact(str(exc))], + "lease_id": lease_id, + "authoritative_source": "control_plane_db", + } + + +@mcp.tool() +def gitea_expire_workflow_leases( + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Expire stale control-plane leases whose expires_at has passed (#601). + + Deterministic reclaim prerequisite. Does not steal active non-expired leases. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + return lease_lifecycle.expire_leases(db) + + +@mcp.tool() +def gitea_abandon_workflow_lease( + lease_id: str, + session_id: str | None = None, + dead_process: bool = False, + missing_worktree: bool = False, + no_open_pr: bool = False, + no_live_mutation_risk: bool = False, + operator_authorized: bool = False, + worktree_path: str | None = None, + owner_pid: int | None = None, + notes: str = "", + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Abandon a control-plane lease with required proof (#601). + + Requires (dead_process or missing_worktree) and no_live_mutation_risk. + Foreign abandon also needs operator_authorized or + (dead_process and missing_worktree and no_open_pr). + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or "session" + sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}" + proof = lease_lifecycle.AbandonProof( + dead_process=bool(dead_process), + missing_worktree=bool(missing_worktree), + no_open_pr=bool(no_open_pr), + no_live_mutation_risk=bool(no_live_mutation_risk), + operator_authorized=bool(operator_authorized), + worktree_path=worktree_path, + owner_pid=owner_pid, + notes=notes or "", + ) + try: + return lease_lifecycle.abandon_lease( + db, + lease_id=lease_id, + requester_session_id=sid, + proof=proof, + ) + except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc: + return { + "success": False, + "outcome": "blocked", + "reasons": [_redact(str(exc))], + "lease_id": lease_id, + "authoritative_source": "control_plane_db", + "file_lock_only": False, + "comment_lease_only": False, + } + + +@mcp.tool() +def gitea_reclaim_expired_workflow_lease( + lease_id: str, + session_id: str | None = None, + role: str | None = None, + worktree_path: str | None = None, + expected_head_sha: str | None = None, + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Reclaim an expired control-plane lease for a new/owner session (#601). + + Deterministic: expire markers applied, then atomic assign+lease with provenance. + Active foreign leases are refused. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + db, errs = _control_plane_db_or_error() + if db is None: + return {"success": False, "reasons": errs} + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or "session" + active_role = _profile_role_kind(profile) or "author" + sid = (session_id or "").strip() or ( + f"{profile_name}-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) + try: + return lease_lifecycle.reclaim_expired_lease( + db, + lease_id=lease_id, + session_id=sid, + role=(role or active_role).strip() or "author", + worktree_path=worktree_path, + expected_head_sha=expected_head_sha, + ) + except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc: + return { + "success": False, + "outcome": "blocked", + "reasons": [_redact(str(exc))], + "lease_id": lease_id, + "authoritative_source": "control_plane_db", + } + + # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": diff --git a/issue_lock_store.py b/issue_lock_store.py index 6a7b4d7..afdc514 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool: return left == right + +def assess_expired_lock_reclaim( + existing_lock: dict[str, Any] | None, + *, + now: datetime | None = None, +) -> dict[str, Any]: + """Decide whether an expired/stale author issue lock may be reclaimed (#601). + + Required proof (any reclaim of non-live lock): + * lease not live (expired or dead pid) + * owner process dead OR worktree missing + * no force-delete of live foreign ownership + """ + if not existing_lock: + return { + "reclaim_allowed": True, + "reasons": ["no existing lock"], + "freshness": assess_lock_freshness(None, now=now), + } + freshness = assess_lock_freshness(existing_lock, now=now) + if freshness.get("live"): + return { + "reclaim_allowed": False, + "reasons": ["lock is still live; cannot reclaim (fail closed)"], + "freshness": freshness, + } + pid = existing_lock.get("session_pid") + if pid is None: + pid = existing_lock.get("pid") + dead = not is_process_alive(pid) if pid is not None else True + wt = str(existing_lock.get("worktree_path") or "") + missing_wt = (not wt) or (not os.path.isdir(os.path.realpath(wt))) + if not (dead or missing_wt): + return { + "reclaim_allowed": False, + "reasons": [ + "expired/stale lock still has live owner pid and present worktree; " + "recovery review required (fail closed)" + ], + "freshness": freshness, + "owner_pid_dead": dead, + "worktree_missing": missing_wt, + } + return { + "reclaim_allowed": True, + "reasons": [ + "non-live lock with dead process and/or missing worktree; " + "sanctioned reclaim allowed" + ], + "freshness": freshness, + "owner_pid_dead": dead, + "worktree_missing": missing_wt, + "prior_branch": existing_lock.get("branch_name"), + "prior_worktree": existing_lock.get("worktree_path"), + "prior_pid": pid, + } + + def assess_same_issue_lease_conflict( existing_lock: dict[str, Any] | None, *, @@ -405,6 +463,11 @@ def assess_same_issue_lease_conflict( and _same_realpath(str(existing_worktree or ""), worktree_path) ) if is_lease_expired(existing_lock, now=now): + reclaim = assess_expired_lock_reclaim(existing_lock, now=now) + if reclaim.get("reclaim_allowed"): + # #601: expired + dead pid / missing worktree may be reclaimed + # through the normal lock path (sanctioned overwrite). + return None return ( f"Issue #{issue_number} has an expired {operation_type} lease on " f"branch '{existing_branch}' from worktree '{existing_worktree}'. " diff --git a/lease_lifecycle.py b/lease_lifecycle.py new file mode 100644 index 0000000..2049962 --- /dev/null +++ b/lease_lifecycle.py @@ -0,0 +1,723 @@ +"""First-class control-plane lease lifecycle (#601). + +Active leases are queryable workflow state. Canonical operations: + +* list / inspect +* adopt (with provenance) +* release (explicit, recorded) +* expire / reclaim +* abandon (requires proof: dead process and/or missing worktree, etc.) + +Authority model: + +* Control-plane DB is the coordination source for assignment/lease. +* File locks and comment-only leases are **not** authoritative alone. +* Reviewer/merger comment leases (``reviewer_pr_lease`` / merger adoption) + remain for Gitea-thread durability; they must not bypass DB assignment when + the control-plane path is in use. + +Fail closed on ambiguous ownership, active foreign leases, mismatched +worktree, stale head, missing capability, and unsafe cleanup. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Mapping + +import control_plane_db as cpd + +# Outcomes / safe next actions (stable vocabulary for tools + tests). +SAFE_OWNER_RESUME = "owner_resume" +SAFE_WAIT_FOREIGN = "wait_foreign_active" +SAFE_RECLAIM_EXPIRED = "reclaim_expired" +SAFE_ABANDON_ALLOWED = "abandon_allowed" +SAFE_RELEASE_OWNED = "release_owned" +SAFE_STALE_PROMPT = "stale_prompt_lease" +SAFE_UNKNOWN = "inspect_only" +SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative" + +LEASE_STATUS_ACTIVE = "active" +LEASE_STATUS_RELEASED = "released" +LEASE_STATUS_EXPIRED = "expired" +LEASE_STATUS_ABANDONED = "abandoned" + +AUTHORITATIVE_SOURCE = "control_plane_db" + + +class LeaseLifecycleError(RuntimeError): + """Fail-closed lifecycle policy error.""" + + +@dataclass(frozen=True) +class AbandonProof: + """Required evidence to abandon a non-owned or expired lease safely.""" + + dead_process: bool = False + missing_worktree: bool = False + same_owner: bool = False + no_open_pr: bool = False + no_live_mutation_risk: bool = False + operator_authorized: bool = False + worktree_path: str | None = None + owner_pid: int | None = None + notes: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "dead_process": self.dead_process, + "missing_worktree": self.missing_worktree, + "same_owner": self.same_owner, + "no_open_pr": self.no_open_pr, + "no_live_mutation_risk": self.no_live_mutation_risk, + "operator_authorized": self.operator_authorized, + "worktree_path": self.worktree_path, + "owner_pid": self.owner_pid, + "notes": self.notes, + } + + def is_sufficient(self) -> bool: + """Abandon requires process death or missing worktree + no mutation risk. + + Foreign active leases additionally need operator_authorized unless the + owner process is dead and the worktree is missing. + """ + if not self.no_live_mutation_risk: + return False + if not (self.dead_process or self.missing_worktree): + return False + if self.same_owner: + return True + # Foreign abandon: operator flag OR (dead + missing worktree + no risk) + if self.operator_authorized: + return True + return bool(self.dead_process and self.missing_worktree and self.no_open_pr) + + +def is_process_alive(pid: int | None) -> bool: + if pid is None: + return False + try: + pid_i = int(pid) + except (TypeError, ValueError): + return False + if pid_i <= 0: + return False + try: + os.kill(pid_i, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + # Exists but not owned by us — treat as alive (fail closed). + return True + except OSError: + return False + + +def worktree_exists(path: str | None) -> bool: + if not path: + return False + try: + return os.path.isdir(os.path.realpath(path)) + except OSError: + return False + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_ts(value: str | None) -> datetime | None: + return cpd._parse_ts(value) + + +def classify_lease_freshness( + lease: Mapping[str, Any], + *, + now: datetime | None = None, + pid_checker: Callable[[int | None], bool] = is_process_alive, + worktree_checker: Callable[[str | None], bool] = worktree_exists, +) -> dict[str, Any]: + """Classify a control-plane lease row for workflow tooling.""" + moment = now or _utc_now() + status = (lease.get("status") or "").strip().lower() + expires = _parse_ts(lease.get("expires_at")) + owner_pid = lease.get("owner_pid") + if owner_pid is None: + owner_pid = lease.get("session_pid") + wt = lease.get("worktree_path") + pid_alive = pid_checker(owner_pid) if owner_pid is not None else None + wt_present = worktree_checker(wt) if wt else None + expired_by_time = bool(expires and expires <= moment) + + if status == LEASE_STATUS_ABANDONED: + freshness = "abandoned" + elif status == LEASE_STATUS_RELEASED: + freshness = "released" + elif status == LEASE_STATUS_EXPIRED or expired_by_time: + freshness = "expired" + elif status != LEASE_STATUS_ACTIVE: + freshness = status or "unknown" + elif pid_alive is False: + freshness = "stale_dead_process" + elif wt is not None and wt_present is False: + freshness = "stale_missing_worktree" + else: + freshness = "active" + + return { + "freshness": freshness, + "status": status, + "expired_by_time": expired_by_time, + "owner_pid": owner_pid, + "owner_pid_alive": pid_alive, + "worktree_path": wt, + "worktree_present": wt_present, + "expires_at": lease.get("expires_at"), + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + + +def decide_safe_next_action( + *, + lease: Mapping[str, Any] | None, + caller_session_id: str, + freshness: Mapping[str, Any] | None = None, + caller_worktree: str | None = None, +) -> dict[str, Any]: + """Return safe_next_action + reasons for a caller inspecting a lease.""" + if not lease: + return { + "safe_next_action": SAFE_STALE_PROMPT, + "reasons": ["lease not found in control-plane DB (stale prompt id?)"], + "block": True, + } + fr = freshness or classify_lease_freshness(lease) + owner = str(lease.get("session_id") or "") + same_owner = owner == str(caller_session_id) + status = fr.get("freshness") + reasons: list[str] = [] + + # Worktree mismatch for owner resume + lease_wt = lease.get("worktree_path") + if ( + same_owner + and lease_wt + and caller_worktree + and os.path.realpath(str(lease_wt)) != os.path.realpath(str(caller_worktree)) + ): + return { + "safe_next_action": SAFE_UNKNOWN, + "reasons": [ + f"worktree mismatch: lease has {lease_wt!r}, caller has " + f"{caller_worktree!r} (fail closed)" + ], + "block": True, + "same_owner": True, + } + + if status in ("abandoned", "released"): + return { + "safe_next_action": SAFE_STALE_PROMPT, + "reasons": [f"lease status is {status}; do not adopt blindly"], + "block": True, + "same_owner": same_owner, + } + + if status == "expired" or fr.get("expired_by_time"): + return { + "safe_next_action": SAFE_RECLAIM_EXPIRED, + "reasons": ["lease expired; reclaim via expire+assign or adopt reclaim path"], + "block": False, + "same_owner": same_owner, + } + + if status in ("stale_dead_process", "stale_missing_worktree"): + if same_owner: + return { + "safe_next_action": SAFE_OWNER_RESUME, + "reasons": [ + f"caller owns lease with freshness={status}; rebind via " + "adopt/owner-resume or abandon with proof" + ], + "block": False, + "same_owner": True, + "also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED], + } + return { + "safe_next_action": SAFE_ABANDON_ALLOWED, + "reasons": [ + f"lease freshness={status}; abandon with required proof then reassign" + ], + "block": False, + "same_owner": False, + } + + if same_owner and status == "active": + return { + "safe_next_action": SAFE_OWNER_RESUME, + "reasons": [ + "caller owns active lease; resume via heartbeat/assign owner-resume " + "or release explicitly" + ], + "block": False, + "same_owner": True, + "also_allowed": [SAFE_RELEASE_OWNED], + } + + if not same_owner and status == "active": + return { + "safe_next_action": SAFE_WAIT_FOREIGN, + "reasons": [ + f"foreign active lease held by session {owner}; " + "do not steal (fail closed)" + ], + "block": True, + "same_owner": False, + "owner_session_id": owner, + } + + return { + "safe_next_action": SAFE_UNKNOWN, + "reasons": [f"unclassified freshness={status}"], + "block": True, + "same_owner": same_owner, + } + + +def non_db_lease_authority_report( + *, + file_lock_present: bool = False, + comment_lease_present: bool = False, + db_lease_present: bool = False, +) -> dict[str, Any]: + """File/comment leases alone are not coordination authority (#601 / #600).""" + authoritative = bool(db_lease_present) + reasons: list[str] = [] + if file_lock_present and not db_lease_present: + reasons.append( + "file lock present but control-plane DB lease absent — " + "file lock is not authoritative alone" + ) + if comment_lease_present and not db_lease_present: + reasons.append( + "comment-only lease present but control-plane DB lease absent — " + "comment lease is not authoritative alone" + ) + if authoritative: + reasons.append("control-plane DB lease is the coordination authority") + return { + "authoritative_source": AUTHORITATIVE_SOURCE if authoritative else None, + "db_lease_present": db_lease_present, + "file_lock_present": file_lock_present, + "comment_lease_present": comment_lease_present, + "safe_next_action": ( + SAFE_UNKNOWN if authoritative else SAFE_NO_AUTHORITY + ), + "reasons": reasons, + "file_lock_only": bool(file_lock_present and not db_lease_present), + "comment_lease_only": bool(comment_lease_present and not db_lease_present), + } + + +def build_adopt_provenance( + *, + adopted_from_session_id: str, + adopted_by_session_id: str, + work_kind: str, + work_number: int, + remote: str, + org: str, + repo: str, + worktree_path: str | None, + expected_head_sha: str | None, + prior_lease_id: str | None, + reason: str, +) -> dict[str, Any]: + return { + "adopted_from_session_id": adopted_from_session_id, + "adopted_by_session_id": adopted_by_session_id, + "work_kind": work_kind, + "work_number": int(work_number), + "remote": remote, + "org": org, + "repo": repo, + "worktree_path": worktree_path, + "expected_head_sha": expected_head_sha, + "prior_lease_id": prior_lease_id, + "reason": reason, + "recorded_at": cpd._ts(), + "source": "lease_lifecycle.adopt", + } + + +def inspect_lease( + db: cpd.ControlPlaneDB, + lease_id: str, + *, + caller_session_id: str, + caller_worktree: str | None = None, +) -> dict[str, Any]: + """Full first-class lease workflow state for one lease id.""" + state = db.get_lease_workflow_state(lease_id) + if not state: + decision = decide_safe_next_action( + lease=None, caller_session_id=caller_session_id + ) + return { + "success": True, + "found": False, + "lease_id": lease_id, + "lease": None, + "freshness": None, + **decision, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + lease = state["lease"] + freshness = classify_lease_freshness(lease) + decision = decide_safe_next_action( + lease=lease, + caller_session_id=caller_session_id, + freshness=freshness, + caller_worktree=caller_worktree, + ) + return { + "success": True, + "found": True, + "lease_id": lease_id, + "lease": lease, + "assignment": state.get("assignment"), + "work_item": state.get("work_item"), + "session": state.get("session"), + "freshness": freshness, + "provenance": state.get("provenance"), + **decision, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + + +def list_active_leases( + db: cpd.ControlPlaneDB, + *, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + role: str | None = None, + include_non_active: bool = False, + limit: int = 100, +) -> dict[str, Any]: + statuses = None if include_non_active else (LEASE_STATUS_ACTIVE,) + rows = db.list_leases( + remote=remote, + org=org, + repo=repo, + role=role, + statuses=statuses, + limit=limit, + ) + enriched = [] + for row in rows: + fr = classify_lease_freshness(row) + enriched.append({**row, "freshness": fr}) + return { + "success": True, + "count": len(enriched), + "leases": enriched, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + "include_non_active": include_non_active, + } + + +def adopt_lease( + db: cpd.ControlPlaneDB, + *, + lease_id: str, + adopter_session_id: str, + role: str, + worktree_path: str | None = None, + expected_head_sha: str | None = None, + owner_pid: int | None = None, + operator_authorized: bool = False, +) -> dict[str, Any]: + """Sanctioned adopt path with provenance; never silent foreign steal.""" + state = db.get_lease_workflow_state(lease_id) + if not state: + raise LeaseLifecycleError( + f"unknown lease_id {lease_id}; stale prompt id (fail closed)" + ) + lease = state["lease"] + work = state["work_item"] + freshness = classify_lease_freshness(lease) + owner = str(lease.get("session_id") or "") + same_owner = owner == str(adopter_session_id) + + if freshness["freshness"] == "active" and not same_owner: + raise LeaseLifecycleError( + f"refusing to steal active foreign lease {lease_id} owned by " + f"{owner} (fail closed)" + ) + + if freshness["freshness"] in ("abandoned", "released"): + raise LeaseLifecycleError( + f"lease {lease_id} is {freshness['freshness']}; cannot adopt " + "(fail closed)" + ) + + # Expired or stale: require abandon-style safety before ownership transfer + # when not same owner; same owner may reclaim. + if not same_owner and freshness["freshness"] in ( + "expired", + "stale_dead_process", + "stale_missing_worktree", + ): + if not operator_authorized and freshness["freshness"] == "expired": + # Deterministic reclaim of expired foreign lease is allowed + # without operator flag (sanctioned expire reclaim). + pass + elif freshness["freshness"] != "expired" and not operator_authorized: + # stale active-looking requires abandon proof path + raise LeaseLifecycleError( + f"lease {lease_id} freshness={freshness['freshness']}; " + "use abandon with proof before foreign adopt (fail closed)" + ) + + provenance = build_adopt_provenance( + adopted_from_session_id=owner, + adopted_by_session_id=adopter_session_id, + work_kind=str(work.get("kind")), + work_number=int(work.get("number")), + remote=str(work.get("remote")), + org=str(work.get("org")), + repo=str(work.get("repo")), + worktree_path=worktree_path, + expected_head_sha=expected_head_sha or lease.get("expected_head_sha"), + prior_lease_id=lease_id, + reason=( + "owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt" + ), + ) + + result = db.adopt_lease( + lease_id=lease_id, + adopter_session_id=adopter_session_id, + role=role, + worktree_path=worktree_path, + expected_head_sha=expected_head_sha, + owner_pid=owner_pid if owner_pid is not None else os.getpid(), + provenance=provenance, + ) + return { + "success": True, + "outcome": result.get("outcome"), + "same_owner": same_owner, + "prior_lease_id": lease_id, + "lease": result.get("lease"), + "assignment": result.get("assignment"), + "provenance": provenance, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + "reasons": result.get("reasons") or [], + } + + +def release_lease( + db: cpd.ControlPlaneDB, + *, + lease_id: str, + session_id: str, +) -> dict[str, Any]: + proof = db.release_lease_recorded(lease_id=lease_id, session_id=session_id) + return { + "success": True, + "outcome": "released", + "lease_id": lease_id, + "session_id": session_id, + "release_proof": proof, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + + +def expire_leases(db: cpd.ControlPlaneDB) -> dict[str, Any]: + n = db.expire_stale_leases() + return { + "success": True, + "outcome": "expired", + "expired_count": int(n), + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + + +def reclaim_expired_lease( + db: cpd.ControlPlaneDB, + *, + lease_id: str, + session_id: str, + role: str, + worktree_path: str | None = None, + expected_head_sha: str | None = None, +) -> dict[str, Any]: + """Expire-if-needed then assign to *session_id* for the same work item.""" + state = db.get_lease_workflow_state(lease_id) + if not state: + raise LeaseLifecycleError(f"unknown lease_id {lease_id}") + lease = state["lease"] + work = state["work_item"] + freshness = classify_lease_freshness(lease) + if freshness["freshness"] == "active": + if lease.get("session_id") != session_id: + raise LeaseLifecycleError( + f"cannot reclaim active foreign lease {lease_id} (fail closed)" + ) + # owner resume + return adopt_lease( + db, + lease_id=lease_id, + adopter_session_id=session_id, + role=role, + worktree_path=worktree_path, + expected_head_sha=expected_head_sha, + ) + if freshness["freshness"] not in ( + "expired", + "stale_dead_process", + "stale_missing_worktree", + "abandoned", + "released", + ): + raise LeaseLifecycleError( + f"lease {lease_id} freshness={freshness['freshness']} not reclaimable" + ) + + # Ensure expired marker is applied + db.expire_stale_leases() + # If still active due to clock skew / non-time stale, force expire this lease + refreshed = db.get_lease_workflow_state(lease_id) + if refreshed and refreshed["lease"].get("status") == "active": + db.force_expire_lease(lease_id, reason="reclaim_expired_lease") + + head = expected_head_sha or work.get("current_head_sha") + assigned = db.assign_and_lease( + session_id=session_id, + role=role, + remote=str(work["remote"]), + org=str(work["org"]), + repo=str(work["repo"]), + kind=str(work["kind"]), + number=int(work["number"]), + expected_head_sha=head, + phase="reclaimed", + worktree_path=worktree_path, + owner_pid=os.getpid(), + ) + if assigned.outcome != "assigned": + raise LeaseLifecycleError( + f"reclaim assign failed: {assigned.outcome} — {assigned.reason}" + ) + provenance = build_adopt_provenance( + adopted_from_session_id=str(lease.get("session_id")), + adopted_by_session_id=session_id, + work_kind=str(work["kind"]), + work_number=int(work["number"]), + remote=str(work["remote"]), + org=str(work["org"]), + repo=str(work["repo"]), + worktree_path=worktree_path, + expected_head_sha=head, + prior_lease_id=lease_id, + reason="reclaim_expired", + ) + db.attach_lease_provenance(assigned.lease_id, provenance) + return { + "success": True, + "outcome": "reclaimed", + "prior_lease_id": lease_id, + "assignment": assigned.as_dict(), + "provenance": provenance, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } + + +def abandon_lease( + db: cpd.ControlPlaneDB, + *, + lease_id: str, + requester_session_id: str, + proof: AbandonProof, +) -> dict[str, Any]: + state = db.get_lease_workflow_state(lease_id) + if not state: + raise LeaseLifecycleError(f"unknown lease_id {lease_id}") + lease = state["lease"] + owner = str(lease.get("session_id") or "") + same_owner = owner == str(requester_session_id) + + # Enrich proof with live checks when not provided + owner_pid = proof.owner_pid + if owner_pid is None: + owner_pid = lease.get("owner_pid") + wt = proof.worktree_path if proof.worktree_path is not None else lease.get( + "worktree_path" + ) + dead = proof.dead_process or ( + owner_pid is not None and not is_process_alive(owner_pid) + ) + missing_wt = proof.missing_worktree or ( + bool(wt) and not worktree_exists(str(wt)) + ) + enriched = AbandonProof( + dead_process=bool(dead), + missing_worktree=bool(missing_wt), + same_owner=same_owner or proof.same_owner, + no_open_pr=proof.no_open_pr, + no_live_mutation_risk=proof.no_live_mutation_risk, + operator_authorized=proof.operator_authorized, + worktree_path=str(wt) if wt else None, + owner_pid=int(owner_pid) if owner_pid is not None else None, + notes=proof.notes, + ) + if not enriched.is_sufficient(): + raise LeaseLifecycleError( + "abandon proof insufficient: need (dead_process or missing_worktree) " + "and no_live_mutation_risk; foreign abandon also needs " + "operator_authorized or (dead+missing_worktree+no_open_pr). " + f"proof={enriched.as_dict()}" + ) + + # Active foreign with live process + present worktree: never abandon without + # operator (already covered by is_sufficient). + result = db.abandon_lease( + lease_id=lease_id, + requester_session_id=requester_session_id, + proof=enriched.as_dict(), + ) + return { + "success": True, + "outcome": "abandoned", + "lease_id": lease_id, + "requester_session_id": requester_session_id, + "prior_owner_session_id": owner, + "abandon_proof": enriched.as_dict(), + "audit": result, + "authoritative_source": AUTHORITATIVE_SOURCE, + "file_lock_only": False, + "comment_lease_only": False, + } diff --git a/task_capability_map.py b/task_capability_map.py index 0c17261..32d6ab0 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -149,6 +149,65 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.read", "role": "author", }, + + # #601 first-class lease lifecycle — inspect/list need read; mutations gate on + # ownership in the control-plane DB (not a separate Gitea write permission). + "list_workflow_leases": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_list_workflow_leases": { + "permission": "gitea.read", + "role": "author", + }, + "inspect_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_inspect_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "adopt_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_adopt_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "release_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_release_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "expire_workflow_leases": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_expire_workflow_leases": { + "permission": "gitea.read", + "role": "author", + }, + "abandon_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_abandon_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "reclaim_expired_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, + "gitea_reclaim_expired_workflow_lease": { + "permission": "gitea.read", + "role": "author", + }, # #612 incident bridge — reconcile uses create_issue for apply; # dry-run needs read only. Tools gate apply paths themselves. "observability_reconcile_incident": { diff --git a/tests/test_control_plane_db.py b/tests/test_control_plane_db.py index c63cd39..7550031 100644 --- a/tests/test_control_plane_db.py +++ b/tests/test_control_plane_db.py @@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase): rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall()) finally: conn.close() - self.assertEqual(rows["schema_version"], "2") + self.assertEqual(rows["schema_version"], "3") self.assertIn("DB coordinates", rows["architecture"]) self.assertIn("bridge", rows["architecture"].lower()) diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py index 3928b19..9c41907 100644 --- a/tests/test_issue_lock_store.py +++ b/tests/test_issue_lock_store.py @@ -103,20 +103,40 @@ class TestIssueLockStore(unittest.TestCase): self.assertIn("live foreign issue lock", block or "") def test_expired_lease_allows_takeover_with_conflict_check(self): + # #601: expired + dead pid / missing worktree → sanctioned reclaim (no block). existing = _lock_record( branch_name="feat/issue-420-other", - worktree_path="/tmp/other", + worktree_path="/tmp/other-does-not-exist-420", + session_pid=1, work_lease=_lease("2000-01-01T00:00:00Z"), ) incoming = _lock_record(worktree_path="/tmp/mine") self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming)) - block = ils.assess_same_issue_lease_conflict( - existing, - issue_number=420, - branch_name="feat/issue-420-server-code-parity", - worktree_path="/tmp/mine", + with mock.patch.object(ils, "is_process_alive", return_value=False): + block = ils.assess_same_issue_lease_conflict( + existing, + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + worktree_path="/tmp/mine", + ) + self.assertIsNone(block) + + # Expired but still-live owner pid AND present worktree → fail closed. + present_wt = self.lock_dir # exists + sticky = _lock_record( + branch_name="feat/issue-420-other", + worktree_path=present_wt, + session_pid=os.getpid(), + work_lease=_lease("2000-01-01T00:00:00Z"), ) - self.assertIn("Recovery review is required", block or "") + with mock.patch.object(ils, "is_process_alive", return_value=True): + blocked = ils.assess_same_issue_lease_conflict( + sticky, + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + worktree_path="/tmp/mine", + ) + self.assertIn("Recovery review is required", blocked or "") def test_same_owner_lease_conflict_allows_refresh(self): worktree = "/tmp/wt-420" diff --git a/tests/test_lease_lifecycle.py b/tests/test_lease_lifecycle.py new file mode 100644 index 0000000..275d20c --- /dev/null +++ b/tests/test_lease_lifecycle.py @@ -0,0 +1,392 @@ +"""Tests for first-class control-plane lease lifecycle (#601).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import timedelta +from unittest import mock + +from allocator_service import allocate_next_work, WorkCandidate +from control_plane_db import ControlPlaneDB, ForeignLeaseError, _ts, _utc_now +import lease_lifecycle as ll +import merger_lease_adoption as mla +import reviewer_pr_lease as rpl +from datetime import datetime, timezone + + +class LeaseLifecycleTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self._tmp.name, "cp.sqlite3") + self.db = ControlPlaneDB(self.db_path) + self.db.upsert_session(session_id="owner", role="author", profile="prgs-author", pid=111) + self.db.upsert_session(session_id="other", role="author", profile="prgs-author", pid=222) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _assign(self, session_id="owner", number=601, **kwargs): + return self.db.assign_and_lease( + session_id=session_id, + role="author", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + kind="issue", + number=number, + allowed_actions=("implement", "comment", "push", "create_pr"), + forbidden_actions=("approve", "merge", "self_select_without_assignment"), + worktree_path=kwargs.pop("worktree_path", self._tmp.name), + owner_pid=kwargs.pop("owner_pid", os.getpid()), + **kwargs, + ) + + def test_list_active_leases_as_workflow_state(self) -> None: + a = self._assign(number=601) + self._assign(session_id="other", number=602) + listed = ll.list_active_leases( + self.db, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools" + ) + self.assertTrue(listed["success"]) + self.assertEqual(listed["count"], 2) + self.assertEqual(listed["authoritative_source"], "control_plane_db") + self.assertFalse(listed["file_lock_only"]) + self.assertFalse(listed["comment_lease_only"]) + numbers = sorted(x["work_number"] for x in listed["leases"]) + self.assertEqual(numbers, [601, 602]) + self.assertIsNotNone(a.lease_id) + + def test_adopt_lease_owner_resume_preserves_provenance(self) -> None: + a = self._assign() + res = ll.adopt_lease( + self.db, + lease_id=a.lease_id, + adopter_session_id="owner", + role="author", + worktree_path=self._tmp.name, + ) + self.assertTrue(res["success"]) + self.assertTrue(res["same_owner"]) + prov = res["provenance"] + self.assertEqual(prov["adopted_from_session_id"], "owner") + self.assertEqual(prov["adopted_by_session_id"], "owner") + self.assertEqual(prov["work_number"], 601) + self.assertEqual(prov["worktree_path"], self._tmp.name) + state = self.db.get_lease_workflow_state(a.lease_id) + self.assertIsNotNone(state) + self.assertEqual(state["lease"]["status"], "active") + + def test_release_lease_explicit_recorded(self) -> None: + a = self._assign() + res = ll.release_lease(self.db, lease_id=a.lease_id, session_id="owner") + self.assertEqual(res["outcome"], "released") + self.assertIn("release_proof", res) + self.assertEqual(res["release_proof"]["status"], "released") + state = self.db.get_lease_workflow_state(a.lease_id) + self.assertEqual(state["lease"]["status"], "released") + + def test_expire_and_reclaim_expired_lease(self) -> None: + a = self._assign(lease_ttl_seconds=1) + past = _utc_now() - timedelta(hours=2) + import sqlite3 + + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE leases SET expires_at = ? WHERE lease_id = ?", + (_ts(past), a.lease_id), + ) + conn.commit() + finally: + conn.close() + exp = ll.expire_leases(self.db) + self.assertGreaterEqual(exp["expired_count"], 1) + reclaimed = ll.reclaim_expired_lease( + self.db, + lease_id=a.lease_id, + session_id="other", + role="author", + worktree_path=self._tmp.name, + ) + self.assertEqual(reclaimed["outcome"], "reclaimed") + self.assertEqual(reclaimed["assignment"]["session_id"], "other") + self.assertEqual( + reclaimed["provenance"]["adopted_from_session_id"], "owner" + ) + self.assertEqual( + reclaimed["provenance"]["adopted_by_session_id"], "other" + ) + + def test_abandon_stale_lease_with_required_proof(self) -> None: + a = self._assign(owner_pid=99999999, worktree_path="/nonexistent/path/for-601") + # Force active but dead pid + missing worktree + proof = ll.AbandonProof( + dead_process=True, + missing_worktree=True, + no_open_pr=True, + no_live_mutation_risk=True, + owner_pid=99999999, + worktree_path="/nonexistent/path/for-601", + ) + res = ll.abandon_lease( + self.db, + lease_id=a.lease_id, + requester_session_id="other", + proof=proof, + ) + self.assertEqual(res["outcome"], "abandoned") + self.assertEqual(res["prior_owner_session_id"], "owner") + self.assertTrue(res["abandon_proof"]["dead_process"]) + state = self.db.get_lease_workflow_state(a.lease_id) + self.assertEqual(state["lease"]["status"], "abandoned") + + def test_refuse_steal_active_foreign_lease(self) -> None: + a = self._assign() + with self.assertRaises(ll.LeaseLifecycleError) as ctx: + ll.adopt_lease( + self.db, + lease_id=a.lease_id, + adopter_session_id="other", + role="author", + ) + self.assertIn("steal", str(ctx.exception).lower()) + decision = ll.inspect_lease( + self.db, a.lease_id, caller_session_id="other" + ) + self.assertEqual(decision["safe_next_action"], ll.SAFE_WAIT_FOREIGN) + self.assertTrue(decision["block"]) + + def test_refuse_ambiguous_lease_ownership_stale_id(self) -> None: + decision = ll.inspect_lease( + self.db, "lease-does-not-exist", caller_session_id="owner" + ) + self.assertFalse(decision["found"]) + self.assertEqual(decision["safe_next_action"], ll.SAFE_STALE_PROMPT) + with self.assertRaises(ll.LeaseLifecycleError): + ll.adopt_lease( + self.db, + lease_id="lease-does-not-exist", + adopter_session_id="owner", + role="author", + ) + + def test_provenance_on_adopt_release_abandon(self) -> None: + a = self._assign() + adopted = ll.adopt_lease( + self.db, + lease_id=a.lease_id, + adopter_session_id="owner", + role="author", + worktree_path=self._tmp.name, + expected_head_sha="abc", + ) + self.assertIn("adopted_from_session_id", adopted["provenance"]) + self.assertIn("adopted_by_session_id", adopted["provenance"]) + released = ll.release_lease( + self.db, lease_id=a.lease_id, session_id="owner" + ) + self.assertEqual(released["release_proof"]["session_id"], "owner") + + b = self._assign(number=700, owner_pid=1, worktree_path="/no/such/wt") + abandoned = ll.abandon_lease( + self.db, + lease_id=b.lease_id, + requester_session_id="owner", + proof=ll.AbandonProof( + dead_process=True, + missing_worktree=True, + no_live_mutation_risk=True, + no_open_pr=True, + ), + ) + self.assertIn("abandon_proof", abandoned) + self.assertEqual(abandoned["abandon_proof"]["dead_process"], True) + + def test_allocator_assignment_lease_compatible(self) -> None: + """Allocator assign+lease still works and is listable/inspectable.""" + cands = [ + WorkCandidate( + kind="issue", + number=601, + labels=("status:ready",), + title="leases", + priority=20, + ) + ] + res = allocate_next_work( + self.db, + session_id="alloc-s", + role="author", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + candidates=cands, + apply=True, + profile_name="prgs-author", + username="jcwalker3", + ) + self.assertEqual(res["outcome"], "assigned_work") + lid = res["assignment"]["lease_id"] + listed = ll.list_active_leases(self.db, remote="prgs") + ids = [x["lease_id"] for x in listed["leases"]] + self.assertIn(lid, ids) + insp = ll.inspect_lease(self.db, lid, caller_session_id="alloc-s") + self.assertTrue(insp["found"]) + self.assertIn( + insp["safe_next_action"], + (ll.SAFE_OWNER_RESUME, ll.SAFE_ABANDON_ALLOWED), + ) + + def test_reviewer_merger_lease_handoff_still_works(self) -> None: + """#536 merger adoption path is independent and must not regress.""" + rpl.clear_session_lease() + body = mla.format_adoption_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=999, + issue_number=601, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-1", + worktree="branches/merge-pr999", + candidate_head="a" * 40, + target_branch="master", + target_branch_sha="b" * 40, + adopted_from_session_id="reviewer-1", + adopted_from_profile="prgs-reviewer", + adopted_from_reviewer_identity="sysadmin", + adopted_from_comment_id=42, + ) + self.assertIn(mla.ADOPTION_MARKER, body) + self.assertIn("adopted_from_session_id: reviewer-1", body) + self.assertIn("adopted_by_profile: prgs-merger", body) + prov = mla.build_lease_provenance( + source=mla.SOURCE_ADOPT, + comment_id=42, + adopted_from_session_id="reviewer-1", + adoption_reason=mla.DEFAULT_ADOPTION_REASON, + ) + rpl.record_session_lease( + { + "pr_number": 999, + "session_id": "merger-1", + "comment_id": 42, + }, + lease_provenance=prov, + ) + proof = mla.describe_session_lease_proof(rpl.get_session_lease()) + self.assertTrue(proof["lease_proof_sanctioned"]) + self.assertEqual(proof["lease_proof_source"], mla.SOURCE_ADOPT) + rpl.clear_session_lease() + + def test_missing_worktree_and_dead_pid_handled_safely(self) -> None: + a = self._assign(owner_pid=1, worktree_path="/definitely/missing/wt-601") + with mock.patch.object(ll, "is_process_alive", return_value=False): + fr = ll.classify_lease_freshness( + self.db.get_lease_workflow_state(a.lease_id)["lease"] + ) + self.assertIn(fr["freshness"], ("stale_dead_process", "stale_missing_worktree")) + # Insufficient abandon proof fails closed + with self.assertRaises(ll.LeaseLifecycleError): + ll.abandon_lease( + self.db, + lease_id=a.lease_id, + requester_session_id="other", + proof=ll.AbandonProof(dead_process=True), # missing no_live_mutation_risk + ) + + def test_file_lock_or_comment_not_authoritative_alone(self) -> None: + report = ll.non_db_lease_authority_report( + file_lock_present=True, + comment_lease_present=False, + db_lease_present=False, + ) + self.assertEqual(report["safe_next_action"], ll.SAFE_NO_AUTHORITY) + self.assertTrue(report["file_lock_only"]) + self.assertIsNone(report["authoritative_source"]) + report2 = ll.non_db_lease_authority_report( + file_lock_present=True, + comment_lease_present=True, + db_lease_present=True, + ) + self.assertEqual(report2["authoritative_source"], "control_plane_db") + self.assertFalse(report2["file_lock_only"]) + self.assertFalse(report2["comment_lease_only"]) + + def test_abandon_proof_is_sufficient_rules(self) -> None: + self.assertFalse(ll.AbandonProof(dead_process=True).is_sufficient()) + self.assertTrue( + ll.AbandonProof( + dead_process=True, + missing_worktree=True, + no_live_mutation_risk=True, + no_open_pr=True, + ).is_sufficient() + ) + self.assertTrue( + ll.AbandonProof( + dead_process=True, + no_live_mutation_risk=True, + same_owner=True, + ).is_sufficient() + ) + self.assertTrue( + ll.AbandonProof( + missing_worktree=True, + no_live_mutation_risk=True, + operator_authorized=True, + ).is_sufficient() + ) + + +class IssueLockExpiredReclaimTest(unittest.TestCase): + def test_expired_lock_reclaim_allowed_when_dead_and_missing_wt(self) -> None: + import issue_lock_store as ils + + lock = { + "issue_number": 601, + "branch_name": "feat/issue-601-x", + "worktree_path": "/no/such/worktree-601", + "session_pid": 1, + "work_lease": { + "operation_type": "author_issue_work", + "expires_at": "2000-01-01T00:00:00Z", + }, + } + with mock.patch.object(ils, "is_process_alive", return_value=False): + res = ils.assess_expired_lock_reclaim(lock) + self.assertTrue(res["reclaim_allowed"]) + # Conflict assessor allows takeover + block = ils.assess_same_issue_lease_conflict( + lock, + issue_number=601, + branch_name="feat/issue-601-first-class-leases", + worktree_path="/tmp/new", + ) + self.assertIsNone(block) + + def test_live_lock_reclaim_refused(self) -> None: + import issue_lock_store as ils + from datetime import datetime, timedelta, timezone + + future = (datetime.now(timezone.utc) + timedelta(hours=2)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + lock = { + "issue_number": 601, + "branch_name": "feat/issue-601-x", + "worktree_path": tempfile.gettempdir(), + "session_pid": os.getpid(), + "work_lease": { + "operation_type": "author_issue_work", + "expires_at": future, + "last_heartbeat_at": future, + }, + } + res = ils.assess_expired_lock_reclaim(lock) + self.assertFalse(res["reclaim_allowed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index abdec4b..45ce67e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -5,6 +5,7 @@ the MCP protocol) with mocked API responses. """ import json import os +import shutil import sys import tempfile import unittest @@ -3811,7 +3812,15 @@ class TestIssueLocking(unittest.TestCase): @patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self, _auth, _api, _git_state): + """#601: expired lease still blocks takeover when owner pid is live AND worktree exists. + + Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests. + This MCP path must remain fail-closed for sticky expired foreign ownership. + """ prgs_repo = mcp_server.REMOTES["prgs"]["repo"] + # Present worktree + live pid => reclaim_allowed=False even though expires_at is past. + sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-") + self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True)) issue_lock_store.save_lock_file( issue_lock_store.lock_file_path( remote="prgs", @@ -3825,15 +3834,22 @@ class TestIssueLocking(unittest.TestCase): "remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": prgs_repo, - "worktree_path": "/tmp/other-worktree", + "worktree_path": sticky_worktree, + "session_pid": os.getpid(), + "pid": os.getpid(), "work_lease": { "operation_type": "author_issue_work", "expires_at": "2000-01-01T00:00:00Z", }, }, ) - with self.assertRaises(RuntimeError) as ctx: - gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") + with patch.object(issue_lock_store, "is_process_alive", return_value=True): + with self.assertRaises(RuntimeError) as ctx: + gitea_lock_issue( + issue_number=196, + branch_name="feat/issue-196-mutations", + remote="prgs", + ) self.assertIn("Recovery review is required before takeover", str(ctx.exception)) @patch("mcp_server.api_get_all", return_value=[])