Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 f34319852e feat: lifecycle role/hazard labels and canonical state mapping (#603)
Add orthogonal role:* (single-active owner) and hazard:* (additive warning)
lifecycle labels plus status:changes-requested, folding the #603 state:*
vocabulary into the canonical status:* set rather than a conflicting parallel
prefix.

- issue_workflow_labels.py: ROLE_/HAZARD_ specs + frozensets; role/hazard
  transition maps and helpers (transition_role_labels, add/clear_hazard_label,
  canonical_role_label, canonical_hazard_label, is_discussion,
  is_implementation_candidate, requires_blocking_reason); state:* -> status:*
  synonym transitions; assess_issue_labels surfaces role/hazard dims and flags
  multiple active role labels
- docs/label-taxonomy.md: role, hazard, state->canonical migration, and
  allocator cross-check sections
- tests: role/hazard/discussion/blocking-reason/state-synonym coverage
- manage_labels.py seeds the new labels automatically via CANONICAL_LABEL_SPECS

Labels remain advisory; control-plane leases (#601) and live PR state stay the
source of truth for mutations (#603 AC2).

Closes #603

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-10 16:17:31 -04:00
sysadmin 2fa97c26fb fix(mcp): load dotenv relative to project root 2026-07-10 15:22:18 -04:00
sysadmin 5ab5fe8583 Merge pull request 'fix: paginate repository-label inventory for gitea_set_issue_labels (Closes #627)' (#629) from fix/issue-627-set-issue-labels-pagination into master
Closes #627

Merges paginated repository-label inventory for gitea_set_issue_labels so later-page labels are not falsely rejected during full-set replacement.
2026-07-10 12:54:23 -05:00
sysadmin f32aaaa3b5 fix: paginate repository-label inventory for gitea_set_issue_labels (#627)
Replace single-page GET labels?limit=100 with api_get_all-backed
_repo_label_id_map so later-page labels (e.g. type:feature,
workflow-hardening) are not falsely rejected during full-set replacement.

Also add post-mutation verification, fix related MCP/CLI label inventory
call sites, and add multi-page regression tests for the #601 reconciler
failure mode.

Closes #627
2026-07-10 13:05:27 -04:00
sysadmin 5347b313ea Merge pull request 'feat: first-class control-plane lease lifecycle (Closes #601)' (#625) from feat/issue-601-first-class-leases into master 2026-07-10 11:23:29 -05:00
sysadmin 1be4600261 fix: address #625 REQUEST_CHANGES for lease reclaim test and EOF blank line
- Update MCP expired-lock recovery test for sticky expired ownership
  (live pid + present worktree still fail closed under #601 reclaim rules)
- Remove trailing blank line at EOF in control_plane_db.py
2026-07-10 11:54:05 -04:00
sysadmin 780ef0b1be feat: first-class control-plane lease lifecycle (Closes #601)
Make active leases queryable workflow state with canonical adopt, release,
expire/reclaim, and abandon operations on the #613 control-plane DB substrate.

- lease_lifecycle.py: policy, proof gates, safe_next_action vocabulary
- control_plane_db: schema v3 provenance columns + list/inspect/adopt/abandon
- MCP tools: gitea_*_workflow_lease(s) for list/inspect/adopt/release/expire/abandon/reclaim
- issue_lock_store: sanctioned reclaim of expired author locks (dead pid / missing wt)
- Tests cover lifecycle, foreign steal refusal, allocator compatibility, merger handoff
2026-07-10 11:30:53 -04:00
sysadmin a654cda058 fix: import Any from typing to resolve IDE/compiler diagnostic warnings 2026-07-10 10:27:21 -04:00
sysadmin ab1c2d7973 Merge pull request 'feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)' (#623) from feat/issue-612-incident-bridge into master 2026-07-10 08:36:47 -05:00
sysadmin 6a179aeb85 feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)
Add phase-1 observability bridge that turns provider observations into
normal Gitea issues and control-plane incident_links rows. Dry-run is
default; apply creates/links through sanctioned Gitea issue paths only.
Raw incidents remain non-assignable; the #600 allocator sees bridge work
only as ordinary Gitea issues. Builds on #613 substrate and #600 allocator.

Closes #612
2026-07-10 03:18:54 -04:00
sysadmin f20c8436aa Merge pull request 'feat: controller-owned allocator API on control-plane DB (Closes #600)' (#622) from feat/issue-600-controller-allocator-api into master 2026-07-10 02:11:59 -05:00
21 changed files with 4478 additions and 160 deletions
+625 -25
View File
@@ -11,9 +11,9 @@ Implements the durable coordination store described by
shared Postgres or single allocator daemon can replace the connection shared Postgres or single allocator daemon can replace the connection
layer later without changing call sites. layer later without changing call sites.
This module is the **substrate** for #600 (allocator policy/tool) and #612 This module is the **substrate** for #600 (allocator policy/tool), #601
(incident bridge). It does **not** implement ``gitea_allocate_next_work`` (first-class lease lifecycle), and #612 (incident bridge). It does **not**
routing policy or provider adapters. implement ``gitea_allocate_next_work`` routing policy or provider adapters.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +29,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Sequence from typing import Any, Iterator, Sequence
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
# Assignable work kinds only — raw monitoring incidents are never work items. # Assignable work kinds only — raw monitoring incidents are never work items.
WORK_KINDS = frozenset({"issue", "pr"}) WORK_KINDS = frozenset({"issue", "pr"})
@@ -301,6 +301,7 @@ class ControlPlaneDB:
try: try:
conn.executescript(_SCHEMA_SQL) conn.executescript(_SCHEMA_SQL)
self._migrate_incident_links_null_scope(conn) self._migrate_incident_links_null_scope(conn)
self._migrate_lease_lifecycle_columns(conn)
conn.execute( conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)), ("schema_version", str(SCHEMA_VERSION)),
@@ -604,6 +605,8 @@ class ControlPlaneDB:
lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS, lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS,
phase: str = "claimed", phase: str = "claimed",
now: datetime | None = None, now: datetime | None = None,
worktree_path: str | None = None,
owner_pid: int | None = None,
) -> AssignmentResult: ) -> AssignmentResult:
"""Atomically create assignment + lease for one Gitea work item. """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), (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( conn.execute(
""" """
INSERT INTO assignments( INSERT INTO assignments(
@@ -876,32 +905,15 @@ class ControlPlaneDB:
} }
def release_lease(self, lease_id: str, *, session_id: str) -> None: 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( row = conn.execute(
"SELECT * FROM leases WHERE lease_id = ?", "SELECT lease_id FROM leases WHERE lease_id = ?",
(lease_id,), (lease_id,),
).fetchone() ).fetchone()
if not row: if not row:
return return
if row["session_id"] != session_id: self.release_lease_recorded(lease_id, 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()),
)
def require_valid_assignment( def require_valid_assignment(
self, self,
@@ -1149,3 +1161,591 @@ class ControlPlaneDB:
(gitea_org, gitea_repo, int(gitea_issue_number)), (gitea_org, gitea_repo, int(gitea_issue_number)),
).fetchone() ).fetchone()
return dict(row) if row else None return dict(row) if row else None
def get_incident_link_by_provider(
self,
*,
provider: str,
provider_issue_id: str,
provider_base_url: str | None = None,
provider_org: str | None = None,
provider_project: str | None = None,
) -> dict[str, Any] | None:
"""Lookup canonical incident_links row by provider key (#612 / #613)."""
base_url = _norm_scope(provider_base_url)
p_org = _norm_scope(provider_org)
p_project = _norm_scope(provider_project)
with self._tx(immediate=False) as conn:
row = conn.execute(
"""
SELECT * FROM incident_links
WHERE provider = ?
AND provider_base_url = ?
AND provider_org = ?
AND provider_project = ?
AND provider_issue_id = ?
LIMIT 1
""",
(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}"
],
}
@@ -49,12 +49,54 @@ Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work`
- `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease). - `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease).
- Coordination source is **always** the control-plane DB — not file locks or comment-only leases. - Coordination source is **always** the control-plane DB — not file locks or comment-only leases.
- Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait). - Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait).
- Raw monitoring incidents are never candidates (#612 remains downstream). - 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_*`
- Bridge converts Sentry/GlitchTip observations → **normal Gitea issues** + `incident_links`.
- Phase-1: `gitea_observability_reconcile_incident(apply=false|true)` with JSON observation.
- Dry-run performs **no** Gitea mutation and **no** DB write.
- Apply reuses existing links or creates one Gitea issue; never invents `work_items.kind=incident`.
- Config: `GITEA_OBSERVABILITY_PROJECTS_JSON` or `GITEA_OBSERVABILITY_PROJECTS_FILE`.
- Provider tokens never appear in issue bodies, links, or tool results.
- Allocator sees bridge work only after a Gitea issue exists.
## Non-goals (intentionally deferred) ## Non-goals (intentionally deferred)
- Sentry/GlitchTip provider adapters and auto-watchdog — **#612** - Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
- Gitea comment/label mirror writers (may be added by allocator tooling later) - Assuming GlitchTip writeback API equals Sentry without verification
- Gitea comment/label mirror writers for every assignment (optional later)
## Tests ## Tests
+67
View File
@@ -39,6 +39,7 @@ one.
| `status:blocked` | Issue is blocked | | `status:blocked` | Issue is blocked |
| `status:needs-review` | Issue work needs review | | `status:needs-review` | Issue work needs review |
| `status:pr-open` | A linked PR is open | | `status:pr-open` | A linked PR is open |
| `status:changes-requested` | Reviewer requested changes on the linked PR |
| `status:approved` | Linked PR is approved | | `status:approved` | Linked PR is approved |
| `status:merged` | Linked PR is merged | | `status:merged` | Linked PR is merged |
| `status:reconcile` | Issue needs reconciliation | | `status:reconcile` | Issue needs reconciliation |
@@ -46,6 +47,72 @@ one.
| `status:duplicate` | Issue is a duplicate | | `status:duplicate` | Issue is a duplicate |
| `status:wontfix` | Issue will not be fixed | | `status:wontfix` | Issue will not be fixed |
## Role Ownership Labels (#603)
A single `role:*` label shows which workflow role currently owns the item. It is
advisory visibility only — the control-plane lease (#601) is the source of truth
for mutation authority. Only one `role:*` label is active at a time; tooling
replaces it on handoff via `transition_role_labels`.
| Label | Use |
| --- | --- |
| `role:author` | Author currently owns the item |
| `role:reviewer` | Reviewer currently owns the item |
| `role:merger` | Merger currently owns the item |
## Hazard Labels (#603)
Hazard labels are orthogonal warning flags. Unlike `status:*` and `role:*`,
**more than one hazard may be active at once**, and a hazard never substitutes
for a live lease / PR-state check. Add/remove with `add_hazard_label` /
`clear_hazard_label`.
| Label | Use |
| --- | --- |
| `hazard:stale-lease` | A stale or expired lease references this item |
| `hazard:workflow-contaminated` | Session/workflow state is contaminated; do not mutate |
| `hazard:conflicted` | Linked PR has merge conflicts |
| `hazard:root-mutation` | Work was mutated in the project root checkout |
| `hazard:manual-state` | Session or lease state was edited manually |
| `hazard:terminal-blocker` | A terminal review/merge lock blocks progress (#332/#602) |
Any item that carries `status:blocked` or any `hazard:*` flag must also have a
blocking-reason / next-action comment (`requires_blocking_reason`).
## `state:*` → canonical mapping (#603 migration)
Issue #603 proposed a parallel `state:*` vocabulary. To avoid a conflicting
second lifecycle prefix, those requested states are folded into the existing
canonical labels rather than introduced as `state:*`. `state:*` is **not** a
supported prefix; use the canonical label on the right.
| Requested `state:*` | Canonical label |
| --- | --- |
| `state:needs-triage` | `status:triage` |
| `state:claimed` | `status:claimed` |
| `state:authoring` | `status:in-progress` |
| `state:needs-review` | `status:needs-review` |
| `state:reviewing` | `status:needs-review` |
| `state:changes-requested` | `status:changes-requested` |
| `state:approved` | `status:approved` |
| `state:merge-ready` | `status:approved` |
| `state:merged` | `status:merged` |
| `state:blocked` | `status:blocked` |
| `state:terminal-blocker` | `hazard:terminal-blocker` |
| `state:abandoned` | `status:wontfix` |
The transition helpers accept these names as synonyms (e.g.
`canonical_status_label("authoring")``status:in-progress`), so callers may use
the #603 wording while a single canonical status stays active.
## Allocator Cross-Check (#603)
Labels are advisory queue hints. The work allocator (#600/#613) uses labels as
one signal but **cross-checks live leases and PR state** and never trusts labels
alone. Discussion issues (`type:discussion`) are excluded from implementation
queues (`is_implementation_candidate`) unless a controller explicitly selects
them.
## Transition Rules ## Transition Rules
Suggested lifecycle: Suggested lifecycle:
+3 -2
View File
@@ -20,14 +20,15 @@ from dotenv import dotenv_values, load_dotenv
import gitea_config import gitea_config
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
# Load standard .env if present # Load standard .env if present
load_dotenv() load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
# Dictionary to store configurations parsed dynamically from .env.* files # Dictionary to store configurations parsed dynamically from .env.* files
DYNAMIC_CONFIGS = {} DYNAMIC_CONFIGS = {}
# Scan all files starting with .env in the project root to load multiple configurations # Scan all files starting with .env in the project root to load multiple configurations
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")): for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
# Skip directories and the example template # Skip directories and the example template
if os.path.basename(env_path) == ".env.example": if os.path.basename(env_path) == ".env.example":
+594 -51
View File
@@ -23,6 +23,8 @@ import contextlib
import subprocess import subprocess
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a # Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
@@ -835,6 +837,8 @@ import mcp_session_state # noqa: E402
import stale_review_decision_lock # noqa: E402 import stale_review_decision_lock # noqa: E402
import allocator_service # noqa: E402 import allocator_service # noqa: E402
import control_plane_db # noqa: E402 import control_plane_db # noqa: E402
import lease_lifecycle # noqa: E402
import incident_bridge # noqa: E402
import agent_temp_artifacts import agent_temp_artifacts
import issue_lock_worktree # noqa: E402 import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402 import issue_lock_provenance # noqa: E402
@@ -1212,12 +1216,32 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non
return sorted(list(issues)) return sorted(list(issues))
def _repo_label_id_map(base: str, auth: str) -> dict[str, int]: def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
labels = api_get_all(f"{base}/labels", auth) or [] """Map repository label names to IDs across **all** label pages (#627).
return {
str(lb["name"]): int(lb["id"]) Uses :func:`api_get_all` so inventories larger than Gitea's per-page cap
for lb in labels (50) are complete. Duplicate names keep the **first-seen** id for
if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None deterministic resolution (fail-open for attach; names still resolve).
} """
labels = api_get_all(f"{base}/labels", auth)
if labels is None:
labels = []
if not isinstance(labels, list):
raise RuntimeError(
"failed to list repository labels: expected a list page sequence, "
f"got {type(labels).__name__}"
)
name_to_id: dict[str, int] = {}
for lb in labels:
if not isinstance(lb, dict):
continue
name = lb.get("name")
lid = lb.get("id")
if not name or lid is None:
continue
key = str(name)
if key not in name_to_id:
name_to_id[key] = int(lid)
return name_to_id
def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]: def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {} issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
@@ -1231,20 +1255,46 @@ def _put_issue_label_names(
names: list[str], names: list[str],
label_ids_by_name: dict[str, int] | None = None, label_ids_by_name: dict[str, int] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Full-set label replacement with complete inventory + post-mutation check.
Missing requested names fail closed before PUT. After PUT, the returned
label set must match the requested names (order-independent) so callers
never silently drop labels (#627).
"""
by_name = label_ids_by_name or _repo_label_id_map(base, auth) by_name = label_ids_by_name or _repo_label_id_map(base, auth)
missing = [name for name in names if name not in by_name] missing = [name for name in names if name not in by_name]
if missing: if missing:
raise RuntimeError( raise RuntimeError(
f"The following labels do not exist on the repository: {missing}. " f"The following labels do not exist on the repository: {missing}. "
"Create the canonical workflow labels first." "Please create them first using gitea_create_label."
) )
ids = [by_name[name] for name in names] ids = [by_name[name] for name in names]
return api_request( res = api_request(
"PUT", "PUT",
f"{base}/issues/{issue_number}/labels", f"{base}/issues/{issue_number}/labels",
auth, auth,
{"labels": ids}, {"labels": ids},
) )
if not isinstance(res, list):
raise RuntimeError(
"Post-mutation label verification failed: expected a list of labels "
f"from Gitea, got {type(res).__name__}."
)
final_names = {
str(lb.get("name"))
for lb in res
if isinstance(lb, dict) and lb.get("name")
}
expected = {str(n) for n in names}
if final_names != expected:
missing_after = sorted(expected - final_names)
extra_after = sorted(final_names - expected)
raise RuntimeError(
"Post-mutation label verification failed: "
f"missing={missing_after} unexpected={extra_after}. "
"Full-set replacement did not match the requested label set."
)
return res
def _transition_issue_status( def _transition_issue_status(
*, *,
@@ -1322,12 +1372,9 @@ def release_in_progress_label(issue_numbers: list[int], remote: str, host: str |
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
try: try:
labels = api_request("GET", f"{base}/labels?limit=100", auth) # Paginated inventory (#627): status labels must resolve even when
label_id = None # the repo has more labels than one Gitea page.
for lb in labels: label_id = _repo_label_id_map(base, auth).get("status:in-progress")
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
except Exception as exc: except Exception as exc:
return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers} return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}
@@ -10282,11 +10329,8 @@ def gitea_cleanup_stale_claims(
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
labels = api_request("GET", f"{base}/labels?limit=100", auth) # Paginated inventory (#627) — do not use single-page labels?limit=100.
label_id = next( label_id = _repo_label_id_map(base, auth).get("status:in-progress")
(lb["id"] for lb in labels if lb.get("name") == "status:in-progress"),
None,
)
if label_id is None: if label_id is None:
raise RuntimeError("Label 'status:in-progress' not found") raise RuntimeError("Label 'status:in-progress' not found")
@@ -10444,29 +10488,16 @@ def gitea_set_issue_labels(
auth = _auth(h) auth = _auth(h)
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
# 1. Fetch existing labels on the repo to resolve names -> IDs # Full-set replacement via paginated name→id map + post-mutation verify (#627).
existing = api_request("GET", f"{base}/labels?limit=100", auth) # Never use single-page GET labels?limit=100 — Gitea caps pages at 50.
name_to_id = {lb["name"]: lb["id"] for lb in existing}
# 2. Check if any requested labels do not exist, and raise error
label_ids = []
missing_labels = []
for name in labels:
if name in name_to_id:
label_ids.append(name_to_id[name])
else:
missing_labels.append(name)
if missing_labels:
raise RuntimeError(
f"The following labels do not exist on the repository: {missing_labels}. "
"Please create them first using gitea_create_label."
)
# 3. PUT the labels to the issue
with _audited("set_issue_labels", host=h, remote=remote, org=o, repo=r, with _audited("set_issue_labels", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"labels": labels}): issue_number=issue_number, request_metadata={"labels": list(labels)}):
res = api_request("PUT", f"{base}/issues/{issue_number}/labels", auth, {"labels": label_ids}) res = _put_issue_label_names(
base=base,
auth=auth,
issue_number=issue_number,
names=list(labels),
)
return res return res
@@ -11244,18 +11275,17 @@ def _allocator_candidates_from_gitea(
# Explicit downstream dependency: #612 waits on #600 allocator. # Explicit downstream dependency: #612 waits on #600 allocator.
dep_unmet = False dep_unmet = False
dep_reason = None dep_reason = None
if int(number) == 612:
dep_unmet = True
dep_reason = (
"issue#612 (incident bridge) remains downstream of #600 "
"allocator and must not be allocated until #600 is complete"
)
# Generic body markers for blocked-on unfinished deps. # Generic body markers for blocked-on unfinished deps.
# (Hard-coded #612→#600 block removed after #600 merged — #612.)
lower_body = body.lower() lower_body = body.lower()
if "blocked on #600" in lower_body or "downstream of #600" in lower_body: if "blocked on #" in lower_body or "downstream of #" in lower_body:
dep_unmet = True # Only treat as unmet when body still marks a live open dependency
dep_reason = dep_reason or ( # pattern; callers may clear labels when deps complete.
f"issue#{number} body marks dependency on #600" dep_unmet = "blocked on #" in lower_body
dep_reason = (
f"issue#{number} body marks an unmet dependency"
if dep_unmet
else None
) )
try: try:
candidates.append( candidates.append(
@@ -11279,6 +11309,210 @@ def _allocator_candidates_from_gitea(
return candidates, reasons return candidates, reasons
@mcp.tool()
def gitea_observability_list_projects(
config_path: str | None = None,
mappings_json: str | None = None,
) -> dict:
"""List configured Sentry/GlitchTip → Gitea project mappings (#612).
Does not load provider tokens. Mappings come from
``GITEA_OBSERVABILITY_PROJECTS_JSON``,
``GITEA_OBSERVABILITY_PROJECTS_FILE``, or explicit arguments.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"projects": [],
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
try:
projects = incident_bridge.load_project_mappings(
config_path=config_path, mappings_json=mappings_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"projects": [],
"reasons": [incident_bridge.redact_text(exc)],
}
return {
"success": True,
"projects": [p.as_dict() for p in projects],
"count": len(projects),
"reasons": [] if projects else ["no project mappings configured"],
"note": (
"Provider API tokens are never returned. "
"Raw incidents are not assignable work (#612)."
),
}
@mcp.tool()
def gitea_observability_reconcile_incident(
observation_json: str,
apply: bool = False,
mappings_json: str | None = None,
config_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
force_gitea_issue_number: int | None = None,
worktree_path: str | None = None,
) -> dict:
"""Reconcile one Sentry/GlitchTip observation into Gitea + incident_links (#612).
Phase-1: pass a sanitized observation as JSON. Dry-run is default
(``apply=false``) no Gitea mutation, no DB write.
When ``apply=true``:
* reuses existing ``incident_links`` row when present
* otherwise creates a **normal Gitea issue** (never a raw work_item incident)
* upserts the canonical ``incident_links`` row on the #613 control-plane DB
Never assigns raw provider incidents to the #600 allocator.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
"raw_incident_assignable": False,
}
if apply:
create_block = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
)
if create_block:
return {
"success": False,
"apply": True,
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": create_block.get("reasons")
if isinstance(create_block, dict)
else [str(create_block)],
"raw_incident_assignable": False,
}
try:
observation = json.loads(observation_json)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"invalid observation_json: {incident_bridge.redact_text(exc)} "
"(fail closed)"
],
"raw_incident_assignable": False,
}
try:
mappings = incident_bridge.load_project_mappings(
config_path=config_path, mappings_json=mappings_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [incident_bridge.redact_text(exc)],
"raw_incident_assignable": False,
}
# Allow org/repo overrides on the observation when not mapped.
if isinstance(observation, dict):
try:
_, o_def, r_def = _resolve(remote, host, org, repo)
except ValueError:
o_def, r_def = org, repo
observation.setdefault("gitea_org", o_def)
observation.setdefault("gitea_repo", r_def)
try:
db = control_plane_db.ControlPlaneDB()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"apply": bool(apply),
"outcome": incident_bridge.OUTCOME_BLOCKED,
"reasons": [
f"control-plane DB unavailable: {incident_bridge.redact_text(exc)} "
"(fail closed, #613)"
],
"raw_incident_assignable": False,
}
create_fn = None
if apply and force_gitea_issue_number is None:
def create_fn(title, body, labels, g_org, g_repo):
return gitea_create_issue(
title=title,
body=body,
remote=remote,
host=host,
org=g_org,
repo=g_repo,
labels=list(labels),
issue_type="bug",
initial_status="ready",
require_workflow_labels=False,
worktree_path=worktree_path,
)
result = incident_bridge.reconcile_incident(
db,
observation=observation if isinstance(observation, dict) else {},
mappings=mappings,
apply=bool(apply),
create_issue_fn=create_fn,
force_gitea_issue_number=force_gitea_issue_number,
)
result["remote"] = remote
return result
@mcp.tool()
def gitea_observability_link_issue(
observation_json: str,
gitea_issue_number: int,
apply: bool = False,
mappings_json: str | None = None,
config_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Explicitly link a provider observation to an existing Gitea issue (#612).
Dry-run by default. ``apply=true`` upserts ``incident_links`` only does
not create a new issue. Fails closed on mapping/fingerprint conflicts.
"""
return gitea_observability_reconcile_incident(
observation_json=observation_json,
apply=apply,
mappings_json=mappings_json,
config_path=config_path,
remote=remote,
host=host,
org=org,
repo=repo,
force_gitea_issue_number=int(gitea_issue_number),
worktree_path=None,
)
@mcp.tool() @mcp.tool()
def gitea_allocate_next_work( def gitea_allocate_next_work(
apply: bool = False, apply: bool = False,
@@ -11416,6 +11650,315 @@ def gitea_allocate_next_work(
return result 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 ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
+788
View File
@@ -0,0 +1,788 @@
"""Observability incident bridge (#612).
Converts Sentry/GlitchTip **observations** into durable **Gitea issues** and
``incident_links`` rows on the #613 control-plane DB.
Hard rules (ADR):
* Gitea owns work; providers own incidents; the bridge only links them.
* Raw monitoring incidents are **never** assignable ``work_items``.
* The #600 allocator sees bridge work only as normal Gitea issues.
* Phase-1 prefers dry-run / explicit reconcile; apply creates/links issues.
* No tokens, DSNs, cookies, or other secrets in bodies, DB, or logs.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Sequence
from control_plane_db import ControlPlaneDB, ControlPlaneError, WORK_KINDS, _norm_scope
PROVIDERS = frozenset({"sentry", "glitchtip"})
OUTCOME_PREVIEW = "preview"
OUTCOME_LINKED = "linked_existing"
OUTCOME_CREATED = "created_issue"
OUTCOME_UPDATED = "updated_link"
OUTCOME_BLOCKED = "blocked"
OUTCOME_NO_ACTION = "no_safe_action"
# Patterns scrubbed from any bridge-facing text (bodies, titles, tags, logs).
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"(?i)\b(api[_-]?token|token|secret|password|passwd)\s*[:=]\s*\S+"
),
re.compile(
r"(?i)\bAuthorization\s*[:=]\s*(?:Bearer\s+)?[A-Za-z0-9._\-+=/]{8,}"
),
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{8,}"),
re.compile(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}"),
re.compile(r"(?i)\b(dsn|sentry_dsn|glitchtip_dsn)\s*[:=]\s*\S+"),
re.compile(r"https?://[^/\s]+:[^@/\s]+@"), # user:pass@host
re.compile(r"(?i)\b(keychain[_-]?id|private[_-]?key)\s*[:=]\s*\S+"),
re.compile(r"(?i)cookie\s*[:=]\s*[^\s;]+"),
re.compile(r"(?i)\bsession[_-]?id\s*[:=]\s*\S+"),
)
_SENSITIVE_TAG_KEYS = frozenset(
{
"authorization",
"cookie",
"set-cookie",
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"dsn",
"sentry_dsn",
"access_token",
"refresh_token",
}
)
DEFAULT_LABELS = (
"type:bug",
"observability",
"status:ready",
)
@dataclass(frozen=True)
class ProjectMapping:
"""Maps a monitoring project to a Gitea repository."""
name: str
provider: str
monitor_base_url: str
monitor_org: str
monitor_project: str
gitea_org: str
gitea_repo: str
default_labels: tuple[str, ...] = DEFAULT_LABELS
environment_filters: tuple[str, ...] = ()
severity_threshold: str | None = None
def __post_init__(self) -> None:
prov = (self.provider or "").strip().lower()
if prov not in PROVIDERS:
raise ControlPlaneError(
f"unknown provider '{self.provider}'; expected one of {sorted(PROVIDERS)}"
)
object.__setattr__(self, "provider", prov)
object.__setattr__(self, "monitor_base_url", (self.monitor_base_url or "").rstrip("/"))
object.__setattr__(self, "monitor_org", (self.monitor_org or "").strip())
object.__setattr__(self, "monitor_project", (self.monitor_project or "").strip())
object.__setattr__(self, "gitea_org", (self.gitea_org or "").strip())
object.__setattr__(self, "gitea_repo", (self.gitea_repo or "").strip())
object.__setattr__(
self,
"default_labels",
tuple(str(x).strip() for x in (self.default_labels or DEFAULT_LABELS) if str(x).strip()),
)
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"provider": self.provider,
"monitor_base_url": self.monitor_base_url,
"monitor_org": self.monitor_org,
"monitor_project": self.monitor_project,
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"default_labels": list(self.default_labels),
"environment_filters": list(self.environment_filters),
"severity_threshold": self.severity_threshold,
}
def matches_observation(self, obs: dict[str, Any]) -> bool:
if (obs.get("provider") or "").strip().lower() != self.provider:
return False
base = (obs.get("provider_base_url") or obs.get("monitor_base_url") or "").rstrip("/")
if base and self.monitor_base_url and base != self.monitor_base_url:
return False
org = (obs.get("provider_org") or obs.get("monitor_org") or "").strip()
if org and self.monitor_org and org != self.monitor_org:
return False
project = (obs.get("provider_project") or obs.get("monitor_project") or "").strip()
if project and self.monitor_project and project != self.monitor_project:
return False
return True
@dataclass
class NormalizedIncident:
provider: str
provider_base_url: str
provider_org: str
provider_project: str
provider_issue_id: str
provider_short_id: str | None = None
provider_permalink: str | None = None
fingerprint: str | None = None
first_seen: str | None = None
last_seen: str | None = None
event_count: int | None = None
status: str = "open"
environment: str | None = None
severity: str | None = None
culprit: str | None = None
title: str = ""
summary: str = ""
tags: dict[str, str] = field(default_factory=dict)
gitea_org: str = ""
gitea_repo: str = ""
default_labels: tuple[str, ...] = DEFAULT_LABELS
def provider_key(self) -> tuple[str, str, str, str, str]:
return (
self.provider,
_norm_scope(self.provider_base_url),
_norm_scope(self.provider_org),
_norm_scope(self.provider_project),
str(self.provider_issue_id),
)
def as_dict(self) -> dict[str, Any]:
return {
"provider": self.provider,
"provider_base_url": self.provider_base_url,
"provider_org": self.provider_org,
"provider_project": self.provider_project,
"provider_issue_id": self.provider_issue_id,
"provider_short_id": self.provider_short_id,
"provider_permalink": self.provider_permalink,
"fingerprint": self.fingerprint,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"event_count": self.event_count,
"status": self.status,
"environment": self.environment,
"severity": self.severity,
"culprit": self.culprit,
"title": self.title,
"summary": self.summary,
"tags": dict(self.tags),
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"default_labels": list(self.default_labels),
}
def redact_text(value: Any) -> str:
"""Redact secret-like material from a string (fail closed to empty)."""
if value is None:
return ""
text = str(value)
for pat in _SECRET_PATTERNS:
text = pat.sub("[REDACTED]", text)
return text
def sanitize_tags(tags: Any) -> dict[str, str]:
if not isinstance(tags, dict):
return {}
out: dict[str, str] = {}
for k, v in tags.items():
key = str(k).strip().lower()
if not key or key in _SENSITIVE_TAG_KEYS:
continue
if any(s in key for s in ("token", "secret", "password", "cookie", "auth", "dsn")):
continue
val = redact_text(v)
if "[REDACTED]" in val:
continue
if len(val) > 200:
val = val[:200] + ""
out[key] = val
return out
def project_mapping_from_dict(data: dict[str, Any]) -> ProjectMapping:
labels = data.get("default_labels") or DEFAULT_LABELS
envs = data.get("environment_filters") or ()
return ProjectMapping(
name=str(data.get("name") or data.get("monitor_project") or "unnamed"),
provider=str(data.get("provider") or ""),
monitor_base_url=str(data.get("monitor_base_url") or data.get("provider_base_url") or ""),
monitor_org=str(data.get("monitor_org") or data.get("provider_org") or ""),
monitor_project=str(data.get("monitor_project") or data.get("provider_project") or ""),
gitea_org=str(data.get("gitea_org") or ""),
gitea_repo=str(data.get("gitea_repo") or ""),
default_labels=tuple(labels),
environment_filters=tuple(envs),
severity_threshold=data.get("severity_threshold"),
)
def load_project_mappings(
*,
config_path: str | None = None,
mappings_json: str | None = None,
) -> list[ProjectMapping]:
"""Load project mappings from JSON env, file, or explicit JSON string.
Env: ``GITEA_OBSERVABILITY_PROJECTS_JSON`` or path
``GITEA_OBSERVABILITY_PROJECTS_FILE``.
"""
raw: Any = None
if mappings_json:
raw = json.loads(mappings_json)
else:
env_json = (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_JSON") or "").strip()
path = (
(config_path or "").strip()
or (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_FILE") or "").strip()
)
if env_json:
raw = json.loads(env_json)
elif path and os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
raw = json.load(fh)
if raw is None:
return []
if isinstance(raw, dict) and "projects" in raw:
raw = raw["projects"]
if not isinstance(raw, list):
raise ControlPlaneError("observability project mappings must be a list")
return [project_mapping_from_dict(item) for item in raw if isinstance(item, dict)]
def resolve_mapping(
observation: dict[str, Any],
mappings: Sequence[ProjectMapping],
*,
explicit: ProjectMapping | None = None,
) -> ProjectMapping | None:
if explicit is not None:
return explicit
matches = [m for m in mappings if m.matches_observation(observation)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ControlPlaneError(
"ambiguous project mapping for observation: "
+ ", ".join(m.name for m in matches)
+ " (fail closed, #612)"
)
# Fallback: observation itself may carry gitea targets
g_org = (observation.get("gitea_org") or "").strip()
g_repo = (observation.get("gitea_repo") or "").strip()
provider = (observation.get("provider") or "").strip().lower()
if g_org and g_repo and provider in PROVIDERS:
return ProjectMapping(
name=f"inline-{provider}",
provider=provider,
monitor_base_url=str(
observation.get("provider_base_url")
or observation.get("monitor_base_url")
or ""
),
monitor_org=str(
observation.get("provider_org") or observation.get("monitor_org") or ""
),
monitor_project=str(
observation.get("provider_project")
or observation.get("monitor_project")
or ""
),
gitea_org=g_org,
gitea_repo=g_repo,
default_labels=tuple(observation.get("default_labels") or DEFAULT_LABELS),
)
return None
def normalize_incident(
observation: dict[str, Any],
mapping: ProjectMapping,
) -> NormalizedIncident:
"""Normalize + sanitize a raw provider observation (fail closed)."""
if not isinstance(observation, dict):
raise ControlPlaneError("observation must be a dict (fail closed, #612)")
provider = (observation.get("provider") or mapping.provider or "").strip().lower()
if provider not in PROVIDERS:
raise ControlPlaneError(
f"unknown provider '{provider}'; expected {sorted(PROVIDERS)} (fail closed)"
)
if provider != mapping.provider:
raise ControlPlaneError(
f"observation provider '{provider}' does not match mapping "
f"'{mapping.provider}' (fail closed, #612)"
)
issue_id = observation.get("provider_issue_id") or observation.get("id")
if issue_id is None or str(issue_id).strip() == "":
raise ControlPlaneError(
"observation missing provider_issue_id (fail closed, #612)"
)
base = (
observation.get("provider_base_url")
or observation.get("monitor_base_url")
or mapping.monitor_base_url
or ""
)
org = (
observation.get("provider_org")
or observation.get("monitor_org")
or mapping.monitor_org
or ""
)
project = (
observation.get("provider_project")
or observation.get("monitor_project")
or mapping.monitor_project
or ""
)
title = redact_text(
observation.get("title")
or observation.get("culprit")
or f"{provider} incident {issue_id}"
)
meta = observation.get("metadata")
meta_value = meta.get("value") if isinstance(meta, dict) else None
raw_sum = (
observation.get("summary")
or observation.get("message")
or meta_value
or title
)
summary = redact_text(raw_sum)
permalink = observation.get("provider_permalink") or observation.get("permalink")
if permalink:
permalink = redact_text(permalink)
if "[REDACTED]" in permalink:
permalink = None
tags = sanitize_tags(observation.get("tags") or {})
event_count = observation.get("event_count") or observation.get("count")
try:
event_count_i = int(event_count) if event_count is not None else None
except (TypeError, ValueError):
event_count_i = None
return NormalizedIncident(
provider=provider,
provider_base_url=str(base).rstrip("/"),
provider_org=str(org).strip(),
provider_project=str(project).strip(),
provider_issue_id=str(issue_id).strip(),
provider_short_id=redact_text(observation.get("provider_short_id") or observation.get("shortId") or "")
or None,
provider_permalink=permalink,
fingerprint=redact_text(observation.get("fingerprint") or "") or None,
first_seen=str(observation.get("first_seen") or observation.get("firstSeen") or "")
or None,
last_seen=str(observation.get("last_seen") or observation.get("lastSeen") or "")
or None,
event_count=event_count_i,
status=str(observation.get("status") or "open").strip().lower() or "open",
environment=redact_text(observation.get("environment") or "") or None,
severity=redact_text(observation.get("severity") or observation.get("level") or "")
or None,
culprit=redact_text(observation.get("culprit") or "") or None,
title=title[:200],
summary=summary[:2000],
tags=tags,
gitea_org=mapping.gitea_org,
gitea_repo=mapping.gitea_repo,
default_labels=mapping.default_labels,
)
def build_gitea_issue_title(inc: NormalizedIncident) -> str:
env = f" [{inc.environment}]" if inc.environment else ""
sev = f" ({inc.severity})" if inc.severity else ""
return redact_text(f"[obs:{inc.provider}]{env}{sev} {inc.title}")[:180]
def build_gitea_issue_body(inc: NormalizedIncident) -> str:
"""Sanitized durable issue body (no secrets)."""
lines = [
"## Observability incident (bridge #612)",
"",
"<!-- mcp-incident-bridge:v1 -->",
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
"",
f"- **provider:** `{inc.provider}`",
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
]
if inc.provider_short_id:
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
if inc.provider_permalink:
lines.append(f"- **provider_url:** {inc.provider_permalink}")
lines.extend(
[
f"- **provider_org/project:** `{inc.provider_org}` / `{inc.provider_project}`",
f"- **base_url:** `{inc.provider_base_url}`",
f"- **fingerprint:** `{inc.fingerprint or ''}`",
f"- **first_seen:** `{inc.first_seen or ''}`",
f"- **last_seen:** `{inc.last_seen or ''}`",
f"- **event_count:** `{inc.event_count if inc.event_count is not None else ''}`",
f"- **environment:** `{inc.environment or ''}`",
f"- **severity:** `{inc.severity or ''}`",
f"- **culprit:** `{inc.culprit or ''}`",
f"- **status:** `{inc.status}`",
"",
"### Summary",
"",
redact_text(inc.summary) or "(no summary)",
"",
"### Safe tags",
"",
]
)
if inc.tags:
for k, v in sorted(inc.tags.items()):
lines.append(f"- `{k}`: `{v}`")
else:
lines.append("- (none)")
lines.extend(
[
"",
"### Canonical next action",
"",
"Author: investigate and fix under normal Gitea workflow. "
"Allocator may select this issue as ordinary Gitea work "
"(never as a raw provider incident).",
"",
"### Bridge notes",
"",
"- Raw Sentry/GlitchTip incidents are **not** assignable work items.",
"- Gitea remains the durable work record; DB stores `incident_links` only.",
"- Provider tokens must never appear in this issue.",
]
)
return "\n".join(lines)
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
"""Fail closed if existing link targets a different Gitea issue/repo."""
eg_org = str(existing.get("gitea_org") or "")
eg_repo = str(existing.get("gitea_repo") or "")
eg_num = existing.get("gitea_issue_number")
if eg_org and eg_org != inc.gitea_org:
return (
f"existing link points to org '{eg_org}', mapping wants "
f"'{inc.gitea_org}' (fail closed, #612)"
)
if eg_repo and eg_repo != inc.gitea_repo:
return (
f"existing link points to repo '{eg_repo}', mapping wants "
f"'{inc.gitea_repo}' (fail closed, #612)"
)
# fingerprint conflict when both present and disagree
ef = (existing.get("fingerprint") or "").strip()
nf = (inc.fingerprint or "").strip()
if ef and nf and ef != nf:
# Same provider issue id with different fingerprints is ambiguous.
return (
f"fingerprint conflict for provider issue {inc.provider_issue_id}: "
f"link has '{ef}', observation has '{nf}' (fail closed, #612)"
)
return None
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
def reconcile_incident(
db: ControlPlaneDB | None,
*,
observation: dict[str, Any],
mappings: Sequence[ProjectMapping] | None = None,
mapping: ProjectMapping | None = None,
apply: bool = False,
create_issue_fn: CreateIssueFn | None = None,
force_gitea_issue_number: int | None = None,
) -> dict[str, Any]:
"""Reconcile one observation into incident_links + optional Gitea issue.
*apply=False* (default): preview only — no DB mutation, no Gitea mutation.
*apply=True*: upsert link; create Gitea issue when none linked (requires
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
Never creates control-plane ``work_items`` for raw incidents.
"""
base: dict[str, Any] = {
"success": False,
"apply": bool(apply),
"outcome": OUTCOME_NO_ACTION,
"performed": False,
"gitea_mutated": False,
"db_mutated": False,
"raw_incident_assignable": False,
"work_item_kind_would_be": None,
"reasons": [],
"skipped": None,
"incident": None,
"existing_link": None,
"gitea_issue": None,
"action": None,
"mapping": None,
"substrate": "control_plane_db.incident_links",
"durable_work_system": "gitea_issues",
}
if db is None:
base["reasons"] = [
"control-plane DB substrate unavailable (fail closed, #613/#612)"
]
base["outcome"] = OUTCOME_BLOCKED
return base
try:
maps = list(mappings or [])
resolved = resolve_mapping(observation, maps, explicit=mapping)
if resolved is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"no project mapping for observation (fail closed, #612); "
"configure GITEA_OBSERVABILITY_PROJECTS_JSON or pass mapping"
]
return base
base["mapping"] = resolved.as_dict()
inc = normalize_incident(observation, resolved)
base["incident"] = inc.as_dict()
except (ControlPlaneError, json.JSONDecodeError, TypeError, ValueError) as exc:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [str(exc)]
return base
# Environment filter (optional)
if resolved.environment_filters and inc.environment:
allowed = {e.lower() for e in resolved.environment_filters}
if inc.environment.lower() not in allowed:
base["outcome"] = OUTCOME_NO_ACTION
base["reasons"] = [
f"environment '{inc.environment}' not in filters "
f"{sorted(allowed)}; skip (policy)"
]
base["success"] = True
base["action"] = "skip_environment_filter"
return base
existing = db.get_incident_link_by_provider(
provider=inc.provider,
provider_issue_id=inc.provider_issue_id,
provider_base_url=inc.provider_base_url,
provider_org=inc.provider_org,
provider_project=inc.provider_project,
)
base["existing_link"] = existing
if existing:
conflict = _link_conflict(existing, inc)
if conflict:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [conflict]
return base
title = build_gitea_issue_title(inc)
body = build_gitea_issue_body(inc)
labels = list(inc.default_labels)
if inc.provider and f"{inc.provider}" not in labels:
labels.append(inc.provider)
if "observability" not in labels:
labels.append("observability")
preview_issue = {
"title": title,
"body_preview": body[:500] + ("" if len(body) > 500 else ""),
"labels": labels,
"gitea_org": inc.gitea_org,
"gitea_repo": inc.gitea_repo,
"would_create": existing is None and force_gitea_issue_number is None,
"would_reuse_issue": int(existing["gitea_issue_number"])
if existing
else force_gitea_issue_number,
}
base["gitea_issue_preview"] = preview_issue
if not apply:
base["success"] = True
base["outcome"] = OUTCOME_PREVIEW
base["action"] = (
"preview_reuse_link" if existing else "preview_create_issue"
)
base["reasons"] = [
"dry-run only (apply=false); no Gitea mutation and no DB write — "
"call again with apply=true to create/link"
]
if existing:
base["gitea_issue"] = {
"number": existing.get("gitea_issue_number"),
"org": existing.get("gitea_org"),
"repo": existing.get("gitea_repo"),
}
# Prove we never treat this as work_item kind incident
base["raw_incident_assignable"] = False
base["work_item_kind_would_be"] = "issue" # only after Gitea issue exists
return base
# --- apply path ---
issue_number: int | None = None
created = False
if existing:
issue_number = int(existing["gitea_issue_number"])
action = "updated_existing_link"
outcome = OUTCOME_UPDATED
elif force_gitea_issue_number is not None:
issue_number = int(force_gitea_issue_number)
action = "link_explicit_issue"
outcome = OUTCOME_LINKED
else:
if create_issue_fn is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"apply=true requires create_issue_fn when no existing link "
"(fail closed, #612)"
]
return base
try:
created_res = create_issue_fn(
title, body, labels, inc.gitea_org, inc.gitea_repo
)
except Exception as exc: # noqa: BLE001
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
f"Gitea issue creation failed: {redact_text(exc)} (fail closed)"
]
return base
number = created_res.get("number") if isinstance(created_res, dict) else None
if number is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"Gitea issue creation returned no issue number (fail closed, #612)"
]
base["create_result"] = created_res
return base
issue_number = int(number)
created = True
action = "created_gitea_issue"
outcome = OUTCOME_CREATED
base["gitea_mutated"] = True
base["create_result"] = {
k: created_res.get(k)
for k in ("number", "success", "performed", "reasons")
if isinstance(created_res, dict)
}
assert issue_number is not None
try:
link = db.upsert_incident_link(
provider=inc.provider,
provider_issue_id=inc.provider_issue_id,
gitea_org=inc.gitea_org,
gitea_repo=inc.gitea_repo,
gitea_issue_number=issue_number,
provider_base_url=inc.provider_base_url,
provider_org=inc.provider_org,
provider_project=inc.provider_project,
provider_short_id=inc.provider_short_id,
provider_permalink=inc.provider_permalink,
fingerprint=inc.fingerprint,
first_seen=inc.first_seen,
last_seen=inc.last_seen,
event_count=inc.event_count,
status=inc.status if not created else "open",
)
except Exception as exc: # noqa: BLE001
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
f"incident_links upsert failed: {redact_text(exc)} (fail closed, #613)"
]
base["gitea_issue"] = {
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"created": created,
}
return base
base["success"] = True
base["performed"] = True
base["db_mutated"] = True
base["outcome"] = outcome
base["action"] = action
base["gitea_issue"] = {
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"created": created,
}
base["link"] = {
k: link.get(k)
for k in (
"link_id",
"provider",
"provider_issue_id",
"gitea_org",
"gitea_repo",
"gitea_issue_number",
"fingerprint",
"event_count",
"status",
"last_sync_at",
)
}
base["reasons"] = [
(
f"reused Gitea issue #{issue_number} for provider "
f"{inc.provider}:{inc.provider_issue_id}"
if not created
else f"created Gitea issue #{issue_number} and stored incident_links row"
),
"raw provider incident is not an assignable work_item "
f"(WORK_KINDS={sorted(WORK_KINDS)})",
]
base["raw_incident_assignable"] = False
base["work_item_kind_would_be"] = "issue"
base["allocator_visible_as"] = {
"kind": "issue",
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"note": "allocator selects normal Gitea issues only (#600/#612)",
}
return base
def assert_not_raw_incident_work_item(kind: str) -> None:
"""Guard used by tests / callers: incidents must not enter WORK_KINDS."""
k = (kind or "").strip().lower()
if k not in WORK_KINDS:
if k in ("sentry_incident", "glitchtip_incident", "incident", "observation"):
raise ControlPlaneError(
f"kind '{k}' is not assignable; bridge must create Gitea issues first (#612)"
)
raise ControlPlaneError(f"kind '{k}' is not assignable")
+63
View File
@@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool:
return left == right 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( def assess_same_issue_lease_conflict(
existing_lock: dict[str, Any] | None, 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) and _same_realpath(str(existing_worktree or ""), worktree_path)
) )
if is_lease_expired(existing_lock, now=now): 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 ( return (
f"Issue #{issue_number} has an expired {operation_type} lease on " f"Issue #{issue_number} has an expired {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}'. " f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
+187 -1
View File
@@ -34,6 +34,11 @@ STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
LabelSpec("status:blocked", "b60205", "Issue is blocked"), LabelSpec("status:blocked", "b60205", "Issue is blocked"),
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"), LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"), LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
LabelSpec(
"status:changes-requested",
"e11d21",
"Reviewer requested changes on the linked PR",
),
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"), LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
LabelSpec("status:merged", "5319e7", "Linked PR is merged"), LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"), LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
@@ -65,8 +70,42 @@ VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
), ),
) )
# Lifecycle role-ownership labels (#603): which workflow role currently owns the
# item. Advisory visibility only — the control-plane lease (#601) remains the
# source of truth for mutation authority. Only one role:* label is active at a
# time, mirroring the single-active-status invariant.
ROLE_LABEL_SPECS: tuple[LabelSpec, ...] = (
LabelSpec("role:author", "1d76db", "Author currently owns the item"),
LabelSpec("role:reviewer", "5319e7", "Reviewer currently owns the item"),
LabelSpec("role:merger", "0e8a16", "Merger currently owns the item"),
)
# Lifecycle hazard labels (#603): orthogonal warning flags surfacing dangerous
# coordination conditions. Unlike status/role, multiple hazard:* labels may be
# active at once, and they never substitute for live lease / PR state checks.
HAZARD_LABEL_SPECS: tuple[LabelSpec, ...] = (
LabelSpec("hazard:stale-lease", "d93f0b", "A stale or expired lease references this item"),
LabelSpec(
"hazard:workflow-contaminated",
"b60205",
"Session/workflow state is contaminated and must not mutate",
),
LabelSpec("hazard:conflicted", "e11d21", "Linked PR has merge conflicts"),
LabelSpec("hazard:root-mutation", "b60205", "Work was mutated in the project root checkout"),
LabelSpec("hazard:manual-state", "d93f0b", "Session or lease state was edited manually"),
LabelSpec(
"hazard:terminal-blocker",
"000000",
"A terminal review/merge lock blocks progress (#332/#602)",
),
)
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = ( CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_LABEL_SPECS TYPE_LABEL_SPECS
+ STATUS_LABEL_SPECS
+ VALIDATION_LABEL_SPECS
+ ROLE_LABEL_SPECS
+ HAZARD_LABEL_SPECS
) )
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS) TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
@@ -74,6 +113,8 @@ STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPE
VALIDATION_LABELS: frozenset[str] = frozenset( VALIDATION_LABELS: frozenset[str] = frozenset(
spec.name for spec in VALIDATION_LABEL_SPECS spec.name for spec in VALIDATION_LABEL_SPECS
) )
ROLE_LABELS: frozenset[str] = frozenset(spec.name for spec in ROLE_LABEL_SPECS)
HAZARD_LABELS: frozenset[str] = frozenset(spec.name for spec in HAZARD_LABEL_SPECS)
CANONICAL_LABELS: frozenset[str] = frozenset( CANONICAL_LABELS: frozenset[str] = frozenset(
spec.name for spec in CANONICAL_LABEL_SPECS spec.name for spec in CANONICAL_LABEL_SPECS
) )
@@ -91,9 +132,15 @@ STATUS_TRANSITIONS: dict[str, str] = {
"blocked": "status:blocked", "blocked": "status:blocked",
"needs_review": "status:needs-review", "needs_review": "status:needs-review",
"needs-review": "status:needs-review", "needs-review": "status:needs-review",
"reviewing": "status:needs-review",
"pr_open": "status:pr-open", "pr_open": "status:pr-open",
"pr-open": "status:pr-open", "pr-open": "status:pr-open",
"changes_requested": "status:changes-requested",
"changes-requested": "status:changes-requested",
"changes": "status:changes-requested",
"approved": "status:approved", "approved": "status:approved",
"merge_ready": "status:approved",
"merge-ready": "status:approved",
"merge": "status:reconcile", "merge": "status:reconcile",
"merged": "status:reconcile", "merged": "status:reconcile",
"reconcile": "status:reconcile", "reconcile": "status:reconcile",
@@ -101,6 +148,37 @@ STATUS_TRANSITIONS: dict[str, str] = {
"complete": "status:done", "complete": "status:done",
"duplicate": "status:duplicate", "duplicate": "status:duplicate",
"wontfix": "status:wontfix", "wontfix": "status:wontfix",
"abandoned": "status:wontfix",
# #603 requested state:* synonyms folded into the canonical status vocabulary
"needs_triage": "status:triage",
"needs-triage": "status:triage",
"authoring": "status:in-progress",
}
# #603: single-active role ownership. Maps role kinds / role:* labels to the
# canonical role label. Mirrors STATUS_TRANSITIONS for the role dimension.
ROLE_TRANSITIONS: dict[str, str] = {
"author": "role:author",
"reviewer": "role:reviewer",
"merger": "role:merger",
}
# #603: hazard flag synonyms. Hazards are additive (not single-active), so this
# only normalizes names; it does not drive replacement.
HAZARD_TRANSITIONS: dict[str, str] = {
"stale_lease": "hazard:stale-lease",
"stale-lease": "hazard:stale-lease",
"workflow_contaminated": "hazard:workflow-contaminated",
"workflow-contaminated": "hazard:workflow-contaminated",
"contaminated": "hazard:workflow-contaminated",
"conflicted": "hazard:conflicted",
"conflict": "hazard:conflicted",
"root_mutation": "hazard:root-mutation",
"root-mutation": "hazard:root-mutation",
"manual_state": "hazard:manual-state",
"manual-state": "hazard:manual-state",
"terminal_blocker": "hazard:terminal-blocker",
"terminal-blocker": "hazard:terminal-blocker",
} }
@@ -155,6 +233,100 @@ def transition_status_labels(
return kept return kept
def role_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
return [name for name in label_names(labels) if name.startswith("role:")]
def hazard_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
return [name for name in label_names(labels) if name.startswith("hazard:")]
def canonical_role_label(role_or_transition: str) -> str:
role = role_or_transition.strip()
if role in ROLE_LABELS:
return role
normalized = role.lower().replace(" ", "-")
try:
return ROLE_TRANSITIONS[normalized]
except KeyError as exc:
raise ValueError(
f"unknown workflow role or transition '{role_or_transition}'"
) from exc
def transition_role_labels(
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
role_or_transition: str,
) -> list[str]:
"""Replace all active role labels with the requested canonical role."""
new_role = canonical_role_label(role_or_transition)
kept = [name for name in label_names(existing_labels) if not name.startswith("role:")]
if new_role not in kept:
kept.append(new_role)
return kept
def canonical_hazard_label(hazard: str) -> str:
name = hazard.strip()
if name in HAZARD_LABELS:
return name
normalized = name.lower().replace(" ", "-")
try:
return HAZARD_TRANSITIONS[normalized]
except KeyError as exc:
raise ValueError(f"unknown workflow hazard '{hazard}'") from exc
def add_hazard_label(
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
hazard: str,
) -> list[str]:
"""Add a hazard flag without disturbing status/role/type labels (additive)."""
new_hazard = canonical_hazard_label(hazard)
kept = label_names(existing_labels)
if new_hazard not in kept:
kept.append(new_hazard)
return kept
def clear_hazard_label(
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
hazard: str,
) -> list[str]:
"""Remove a single hazard flag, leaving all other labels intact."""
target = canonical_hazard_label(hazard)
return [name for name in label_names(existing_labels) if name != target]
def is_discussion(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> bool:
return "type:discussion" in type_labels(labels)
def is_implementation_candidate(
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
) -> bool:
"""Whether an item may enter an implementation queue on labels alone.
Discussion issues are excluded (#603 AC3) unless a controller explicitly
selects them; the allocator still cross-checks live lease/PR state and never
trusts labels alone (#603 AC2).
"""
return not is_discussion(labels)
def requires_blocking_reason(
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
) -> bool:
"""Whether the item must carry a blocking-reason / next-action comment (AC4).
True when blocked or when any hazard flag is present.
"""
names = label_names(labels)
if "status:blocked" in names:
return True
return any(name.startswith("hazard:") for name in names)
def labels_for_new_issue( def labels_for_new_issue(
issue_type: str | None = None, issue_type: str | None = None,
initial_status: str | None = None, initial_status: str | None = None,
@@ -188,6 +360,8 @@ def assess_issue_labels(
names = label_names(labels) names = label_names(labels)
found_types = type_labels(names) found_types = type_labels(names)
found_statuses = status_labels(names) found_statuses = status_labels(names)
found_roles = role_labels(names)
found_hazards = hazard_labels(names)
errors: list[str] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
@@ -202,6 +376,10 @@ def assess_issue_labels(
"issue has multiple active status:* labels: " "issue has multiple active status:* labels: "
+ ", ".join(found_statuses) + ", ".join(found_statuses)
) )
if len(found_roles) > 1:
errors.append(
"issue has multiple active role:* labels: " + ", ".join(found_roles)
)
for name in found_types: for name in found_types:
if name not in TYPE_LABELS: if name not in TYPE_LABELS:
@@ -209,12 +387,20 @@ def assess_issue_labels(
for name in found_statuses: for name in found_statuses:
if name not in STATUS_LABELS: if name not in STATUS_LABELS:
warnings.append(f"unknown status label '{name}'") warnings.append(f"unknown status label '{name}'")
for name in found_roles:
if name not in ROLE_LABELS:
warnings.append(f"unknown role label '{name}'")
for name in found_hazards:
if name not in HAZARD_LABELS:
warnings.append(f"unknown hazard label '{name}'")
return { return {
"valid": not errors, "valid": not errors,
"labels": names, "labels": names,
"type_labels": found_types, "type_labels": found_types,
"status_labels": found_statuses, "status_labels": found_statuses,
"role_labels": found_roles,
"hazard_labels": found_hazards,
"errors": errors, "errors": errors,
"warnings": warnings, "warnings": warnings,
} }
+723
View File
@@ -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,
}
+12 -4
View File
@@ -22,7 +22,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python: if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv) os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import get_auth_header, api_request, repo_api_url from gitea_auth import get_auth_header, api_request, api_get_all, repo_api_url
import issue_workflow_labels import issue_workflow_labels
HOST = "gitea.dadeschools.net" HOST = "gitea.dadeschools.net"
@@ -82,9 +82,17 @@ def api(method, path, auth, payload=None):
def _labels_by_name(auth): def _labels_by_name(auth):
"""Return {label name: id} for the repo's existing labels.""" """Return {label name: id} for the repo's existing labels (all pages, #627)."""
existing = api("GET", "/labels?limit=100", auth) or [] existing = api_get_all(f"{BASE_URL}/labels", auth) or []
return {lb["name"]: lb["id"] for lb in existing} name_to_id = {}
for lb in existing:
if not isinstance(lb, dict):
continue
name = lb.get("name")
lid = lb.get("id")
if name and lid is not None and name not in name_to_id:
name_to_id[name] = lid
return name_to_id
def create_labels(auth, dry=False): def create_labels(auth, dry=False):
+5 -5
View File
@@ -17,7 +17,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
from gitea_auth import ( from gitea_auth import (
get_auth_header, resolve_remote, add_remote_args, get_auth_header, resolve_remote, add_remote_args,
api_request, repo_api_url, api_request, api_get_all, repo_api_url,
) )
LABEL_NAME = "status:in-progress" LABEL_NAME = "status:in-progress"
@@ -45,12 +45,12 @@ def main(argv=None):
base = repo_api_url(host, org, repo) base = repo_api_url(host, org, repo)
try: try:
# Find the label ID # Paginated inventory (#627): Gitea caps single pages at 50.
labels = api_request("GET", f"{base}/labels?limit=100", auth) labels = api_get_all(f"{base}/labels", auth) or []
label_id = None label_id = None
for lb in labels: for lb in labels:
if lb["name"] == LABEL_NAME: if lb.get("name") == LABEL_NAME:
label_id = lb["id"] label_id = lb.get("id")
break break
if label_id is None: if label_id is None:
+86
View File
@@ -150,6 +150,92 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"role": "author", "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": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_reconcile_incident": {
"permission": "gitea.read",
"role": "author",
},
"observability_list_projects": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_list_projects": {
"permission": "gitea.read",
"role": "author",
},
"observability_link_issue": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_link_issue": {
"permission": "gitea.read",
"role": "author",
},
"reconcile_landed_pr": { "reconcile_landed_pr": {
"permission": "gitea.read", "permission": "gitea.read",
"role": "author", "role": "author",
+1 -1
View File
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall()) rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
finally: finally:
conn.close() conn.close()
self.assertEqual(rows["schema_version"], "2") self.assertEqual(rows["schema_version"], "3")
self.assertIn("DB coordinates", rows["architecture"]) self.assertIn("DB coordinates", rows["architecture"])
self.assertIn("bridge", rows["architecture"].lower()) self.assertIn("bridge", rows["architecture"].lower())
+345
View File
@@ -0,0 +1,345 @@
"""Tests for observability incident bridge (#612) on #613 substrate."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from control_plane_db import ControlPlaneDB, InvalidWorkKindError, WORK_KINDS
from incident_bridge import (
OUTCOME_BLOCKED,
OUTCOME_CREATED,
OUTCOME_LINKED,
OUTCOME_PREVIEW,
OUTCOME_UPDATED,
ProjectMapping,
assert_not_raw_incident_work_item,
build_gitea_issue_body,
load_project_mappings,
normalize_incident,
project_mapping_from_dict,
reconcile_incident,
redact_text,
sanitize_tags,
)
def _mapping(**kwargs) -> ProjectMapping:
base = dict(
name="gitea-tools-mcp",
provider="sentry",
monitor_base_url="https://sentry.prgs.cc",
monitor_org="prgs",
monitor_project="gitea-tools-mcp",
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
default_labels=("type:bug", "observability", "sentry", "status:ready"),
)
base.update(kwargs)
return ProjectMapping(**base)
def _obs(**kwargs) -> dict:
base = dict(
provider="sentry",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
provider_issue_id="ISSUE-100",
provider_short_id="GITEA-TOOLS-1A",
provider_permalink="https://sentry.prgs.cc/organizations/prgs/issues/100/",
fingerprint="fp-abc",
title="TypeError: boom",
summary="TypeError: boom in handle_request",
first_seen="2026-07-01T00:00:00Z",
last_seen="2026-07-10T00:00:00Z",
event_count=3,
environment="production",
severity="error",
culprit="handle_request",
tags={"runtime": "python", "release": "1.2.3"},
)
base.update(kwargs)
return base
class IncidentBridgeTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
self.mapping = _mapping()
self.created: list[dict] = []
def tearDown(self) -> None:
self._tmp.cleanup()
def _create_issue(self, title, body, labels, g_org, g_repo):
n = 9000 + len(self.created)
rec = {
"number": n,
"success": True,
"performed": True,
"title": title,
"body": body,
"labels": labels,
"org": g_org,
"repo": g_repo,
}
self.created.append(rec)
return rec
def test_redact_secrets(self) -> None:
dirty = "token=supersecret123 Authorization: Bearer abcdefghijklmnop"
clean = redact_text(dirty)
self.assertNotIn("supersecret", clean)
self.assertNotIn("abcdefghijklmnop", clean)
self.assertIn("[REDACTED]", clean)
tags = sanitize_tags(
{"token": "x", "runtime": "py", "password": "nope", "release": "1.0"}
)
self.assertEqual(tags, {"runtime": "py", "release": "1.0"})
def test_body_contains_no_secrets(self) -> None:
inc = normalize_incident(
_obs(
summary="fail password=hunter2 token: abc",
tags={"cookie": "sid=1", "runtime": "py"},
),
self.mapping,
)
body = build_gitea_issue_body(inc)
self.assertNotIn("hunter2", body)
self.assertNotIn("sid=1", body)
self.assertIn("provider_issue_id", body)
self.assertIn("not** assignable", body.lower())
def test_preview_no_mutation(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=False,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertFalse(res["gitea_mutated"])
self.assertFalse(res["db_mutated"])
self.assertFalse(res["raw_incident_assignable"])
self.assertEqual(len(self.created), 0)
self.assertIsNone(
self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
)
def test_apply_creates_issue_and_link(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_CREATED)
self.assertTrue(res["gitea_mutated"])
self.assertTrue(res["db_mutated"])
self.assertEqual(len(self.created), 1)
self.assertEqual(res["gitea_issue"]["number"], self.created[0]["number"])
link = self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
self.assertIsNotNone(link)
self.assertEqual(int(link["gitea_issue_number"]), self.created[0]["number"])
self.assertEqual(res["allocator_visible_as"]["kind"], "issue")
def test_duplicate_observation_reuses_issue(self) -> None:
first = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
second = reconcile_incident(
self.db,
observation=_obs(event_count=9, last_seen="2026-07-11T00:00:00Z"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(first["outcome"], OUTCOME_CREATED)
self.assertEqual(second["outcome"], OUTCOME_UPDATED)
self.assertEqual(len(self.created), 1)
self.assertEqual(
second["gitea_issue"]["number"], first["gitea_issue"]["number"]
)
link = self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
self.assertEqual(int(link["event_count"]), 9)
def test_explicit_link_existing_issue(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(provider_issue_id="ISSUE-200"),
mapping=self.mapping,
apply=True,
force_gitea_issue_number=555,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_LINKED)
self.assertEqual(len(self.created), 0)
self.assertEqual(res["gitea_issue"]["number"], 555)
link = self.db.get_incident_link_for_gitea_issue(
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=555,
)
self.assertIsNotNone(link)
def test_fingerprint_conflict_fails_closed(self) -> None:
reconcile_incident(
self.db,
observation=_obs(fingerprint="fp-a"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
res = reconcile_incident(
self.db,
observation=_obs(fingerprint="fp-b"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
self.assertIn("fingerprint conflict", res["reasons"][0].lower())
self.assertEqual(len(self.created), 1)
def test_ambiguous_mapping_fails_closed(self) -> None:
maps = [
_mapping(name="a", monitor_project="gitea-tools-mcp"),
_mapping(name="b", monitor_project="gitea-tools-mcp"),
]
res = reconcile_incident(
self.db,
observation=_obs(),
mappings=maps,
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
self.assertIn("ambiguous", res["reasons"][0].lower())
def test_missing_provider_issue_id_fails_closed(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(provider_issue_id=""),
mapping=self.mapping,
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
def test_raw_incident_not_work_kind(self) -> None:
self.assertNotIn("sentry_incident", WORK_KINDS)
with self.assertRaises(InvalidWorkKindError):
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="sentry_incident",
number=1,
)
with self.assertRaises(Exception):
assert_not_raw_incident_work_item("glitchtip_incident")
def test_db_unavailable(self) -> None:
res = reconcile_incident(
None,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertFalse(res["success"])
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
def test_structured_skip_reason_environment(self) -> None:
m = _mapping(environment_filters=("staging",))
res = reconcile_incident(
self.db,
observation=_obs(environment="production"),
mapping=m,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["action"], "skip_environment_filter")
self.assertEqual(len(self.created), 0)
def test_load_mappings_json(self) -> None:
payload = json.dumps(
{
"projects": [
{
"name": "gt",
"provider": "glitchtip",
"monitor_base_url": "https://glitchtip.example",
"monitor_org": "org",
"monitor_project": "proj",
"gitea_org": "Scaled-Tech-Consulting",
"gitea_repo": "Gitea-Tools",
}
]
}
)
maps = load_project_mappings(mappings_json=payload)
self.assertEqual(len(maps), 1)
self.assertEqual(maps[0].provider, "glitchtip")
def test_glitchtip_provider_accepted(self) -> None:
m = _mapping(provider="glitchtip", monitor_base_url="https://glitchtip.example")
res = reconcile_incident(
self.db,
observation=_obs(
provider="glitchtip",
provider_base_url="https://glitchtip.example",
),
mapping=m,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_CREATED)
self.assertEqual(res["incident"]["provider"], "glitchtip")
def test_allocator_only_sees_issue_kind(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
vis = res["allocator_visible_as"]
self.assertEqual(vis["kind"], "issue")
self.assertIn(vis["kind"], WORK_KINDS)
self.assertFalse(res["raw_incident_assignable"])
if __name__ == "__main__":
unittest.main()
+27 -7
View File
@@ -103,20 +103,40 @@ class TestIssueLockStore(unittest.TestCase):
self.assertIn("live foreign issue lock", block or "") self.assertIn("live foreign issue lock", block or "")
def test_expired_lease_allows_takeover_with_conflict_check(self): def test_expired_lease_allows_takeover_with_conflict_check(self):
# #601: expired + dead pid / missing worktree → sanctioned reclaim (no block).
existing = _lock_record( existing = _lock_record(
branch_name="feat/issue-420-other", 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"), work_lease=_lease("2000-01-01T00:00:00Z"),
) )
incoming = _lock_record(worktree_path="/tmp/mine") incoming = _lock_record(worktree_path="/tmp/mine")
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming)) self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
block = ils.assess_same_issue_lease_conflict( with mock.patch.object(ils, "is_process_alive", return_value=False):
existing, block = ils.assess_same_issue_lease_conflict(
issue_number=420, existing,
branch_name="feat/issue-420-server-code-parity", issue_number=420,
worktree_path="/tmp/mine", 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): def test_same_owner_lease_conflict_allows_refresh(self):
worktree = "/tmp/wt-420" worktree = "/tmp/wt-420"
+134
View File
@@ -69,5 +69,139 @@ class TestIssueWorkflowStatusTransitions(unittest.TestCase):
self.assertEqual(result, ["type:process", "status:duplicate"]) self.assertEqual(result, ["type:process", "status:duplicate"])
class TestLifecycleRoleLabels(unittest.TestCase):
def test_role_transition_author_to_reviewer_to_merger_single_active(self):
after_author = labels.transition_role_labels(
["type:feature", "status:pr-open"], "author"
)
self.assertEqual(after_author, ["type:feature", "status:pr-open", "role:author"])
after_reviewer = labels.transition_role_labels(after_author, "reviewer")
self.assertEqual(
after_reviewer, ["type:feature", "status:pr-open", "role:reviewer"]
)
self.assertNotIn("role:author", after_reviewer)
after_merger = labels.transition_role_labels(after_reviewer, "merger")
self.assertEqual(labels.role_labels(after_merger), ["role:merger"])
def test_role_transition_accepts_canonical_label(self):
result = labels.transition_role_labels(["type:bug"], "role:reviewer")
self.assertEqual(result, ["type:bug", "role:reviewer"])
def test_unknown_role_raises(self):
with self.assertRaises(ValueError):
labels.canonical_role_label("wizard")
def test_assess_reports_multiple_active_role_labels(self):
result = labels.assess_issue_labels(
["type:feature", "status:pr-open", "role:author", "role:reviewer"]
)
self.assertFalse(result["valid"])
self.assertTrue(
any("multiple active role:* labels" in err for err in result["errors"])
)
self.assertEqual(
sorted(result["role_labels"]), ["role:author", "role:reviewer"]
)
class TestLifecycleHazardLabels(unittest.TestCase):
def test_hazards_are_additive_and_multiple(self):
one = labels.add_hazard_label(["type:bug", "status:blocked"], "conflicted")
two = labels.add_hazard_label(one, "stale-lease")
self.assertIn("hazard:conflicted", two)
self.assertIn("hazard:stale-lease", two)
# status/type untouched
self.assertIn("status:blocked", two)
self.assertIn("type:bug", two)
self.assertEqual(len(labels.hazard_labels(two)), 2)
def test_add_hazard_is_idempotent(self):
once = labels.add_hazard_label(["type:bug", "status:ready"], "root-mutation")
twice = labels.add_hazard_label(once, "root_mutation")
self.assertEqual(twice.count("hazard:root-mutation"), 1)
def test_clear_hazard_leaves_others_intact(self):
start = ["type:bug", "status:blocked", "hazard:conflicted", "hazard:stale-lease"]
cleared = labels.clear_hazard_label(start, "conflicted")
self.assertNotIn("hazard:conflicted", cleared)
self.assertIn("hazard:stale-lease", cleared)
self.assertIn("status:blocked", cleared)
def test_terminal_blocker_hazard_normalizes(self):
self.assertEqual(
labels.canonical_hazard_label("terminal-blocker"),
"hazard:terminal-blocker",
)
def test_assess_surfaces_hazard_labels(self):
result = labels.assess_issue_labels(
["type:bug", "status:blocked", "hazard:conflicted"]
)
self.assertEqual(result["hazard_labels"], ["hazard:conflicted"])
class TestDiscussionExclusion(unittest.TestCase):
def test_discussion_issue_excluded_from_implementation_queue(self):
self.assertTrue(labels.is_discussion(["type:discussion", "status:ready"]))
self.assertFalse(
labels.is_implementation_candidate(["type:discussion", "status:ready"])
)
def test_non_discussion_issue_is_candidate(self):
self.assertTrue(
labels.is_implementation_candidate(["type:feature", "status:ready"])
)
class TestBlockingReasonRequirement(unittest.TestCase):
def test_blocked_requires_reason(self):
self.assertTrue(
labels.requires_blocking_reason(["type:bug", "status:blocked"])
)
def test_hazard_requires_reason(self):
self.assertTrue(
labels.requires_blocking_reason(
["type:bug", "status:ready", "hazard:stale-lease"]
)
)
def test_clean_ready_issue_needs_no_reason(self):
self.assertFalse(
labels.requires_blocking_reason(["type:feature", "status:ready"])
)
class TestState603SynonymTransitions(unittest.TestCase):
def test_authoring_maps_to_in_progress(self):
self.assertEqual(
labels.canonical_status_label("authoring"), "status:in-progress"
)
def test_changes_requested_transition(self):
result = labels.transition_status_labels(
["type:feature", "status:needs-review"], "changes-requested"
)
self.assertEqual(result, ["type:feature", "status:changes-requested"])
def test_merge_ready_maps_to_approved(self):
self.assertEqual(labels.canonical_status_label("merge-ready"), "status:approved")
def test_abandoned_maps_to_wontfix(self):
self.assertEqual(labels.canonical_status_label("abandoned"), "status:wontfix")
def test_blocked_and_merged_transitions(self):
blocked = labels.transition_status_labels(
["type:bug", "status:in-progress"], "blocked"
)
self.assertEqual(blocked, ["type:bug", "status:blocked"])
merged = labels.transition_status_labels(
["type:feature", "status:approved"], "merged"
)
self.assertEqual(merged, ["type:feature", "status:reconcile"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+392
View File
@@ -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()
+29 -34
View File
@@ -28,33 +28,31 @@ class TestLabelCreation(unittest.TestCase):
"""Verify create-or-skip logic for the label set.""" """Verify create-or-skip logic for the label set."""
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api") @patch("manage_labels.api")
def test_skips_existing_labels(self, mock_api, _auth): def test_skips_existing_labels(self, mock_api, mock_get_all, _auth):
# Simulate all labels already exist # Simulate all labels already exist (paginated inventory #627)
existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)] existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)]
mock_api.return_value = existing # first call is GET /labels mock_get_all.return_value = existing
# Patch sys.argv to avoid --dry # Patch sys.argv to avoid --dry
with patch.object(sys, "argv", ["manage_labels.py"]): with patch.object(sys, "argv", ["manage_labels.py"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
# The GET call happens, but no POST calls for label creation mock_get_all.assert_called()
get_calls = [c for c in mock_api.call_args_list if c[0][0] == "GET"]
post_label_calls = [ post_label_calls = [
c for c in mock_api.call_args_list c for c in mock_api.call_args_list
if c[0][0] == "POST" and c[0][1] == "/labels" if c[0][0] == "POST" and c[0][1] == "/labels"
] ]
self.assertGreaterEqual(len(get_calls), 1)
self.assertEqual(len(post_label_calls), 0) self.assertEqual(len(post_label_calls), 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_creates_missing_labels(self, mock_api, _auth): def test_creates_missing_labels(self, mock_api, _get_all, _auth):
# Simulate no existing labels # Simulate no existing labels
def side_effect(method, path, auth, payload=None): def side_effect(method, path, auth, payload=None):
if method == "GET" and "/labels" in path:
return [] # no existing labels
if method == "POST" and path == "/labels": if method == "POST" and path == "/labels":
return {"id": 999, "name": payload["name"]} return {"id": 999, "name": payload["name"]}
if method == "PUT": if method == "PUT":
@@ -79,15 +77,14 @@ class TestLabelCreation(unittest.TestCase):
class TestDryRun(unittest.TestCase): class TestDryRun(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_dry_run_makes_no_writes(self, mock_api, _auth): def test_dry_run_makes_no_writes(self, mock_api, _get_all, _auth):
mock_api.return_value = [] # no existing labels
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]): with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
# Only the GET call should be made, no POST or PUT # Dry run should not call write methods via api()
for c in mock_api.call_args_list: for c in mock_api.call_args_list:
method = c[0][0] method = c[0][0]
self.assertEqual(method, "GET", self.assertEqual(method, "GET",
@@ -100,13 +97,13 @@ class TestDryRun(unittest.TestCase):
class TestLabelMapping(unittest.TestCase): class TestLabelMapping(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api") @patch("manage_labels.api")
def test_applies_mapping_to_issues(self, mock_api, _auth): def test_applies_mapping_to_issues(self, mock_api, mock_get_all, _auth):
existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)] existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)]
mock_get_all.return_value = existing
def side_effect(method, path, auth, payload=None): def side_effect(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT": if method == "PUT":
return [{"name": "applied"}] return [{"name": "applied"}]
return None return None
@@ -158,11 +155,10 @@ class TestModes(unittest.TestCase):
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list] return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_create_labels_only_no_mapping(self, mock_api, _auth): def test_create_labels_only_no_mapping(self, mock_api, _get_all, _auth):
def se(method, path, auth, payload=None): def se(method, path, auth, payload=None):
if method == "GET":
return [] # no existing labels
if method == "POST" and path == "/labels": if method == "POST" and path == "/labels":
return {"id": 1, "name": payload["name"]} return {"id": 1, "name": payload["name"]}
return None return None
@@ -173,14 +169,14 @@ class TestModes(unittest.TestCase):
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api") @patch("manage_labels.api")
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth): def test_apply_mapping_only_no_label_creation(self, mock_api, mock_get_all, _auth):
existing = [_make_label(l["name"], i + 1) existing = [_make_label(l["name"], i + 1)
for i, l in enumerate(manage_labels.LABELS)] for i, l in enumerate(manage_labels.LABELS)]
mock_get_all.return_value = existing
def se(method, path, auth, payload=None): def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT": if method == "PUT":
return [{"name": "applied"}] return [{"name": "applied"}]
return None return None
@@ -192,13 +188,10 @@ class TestModes(unittest.TestCase):
self.assertEqual(len(put_calls), len(manage_labels.MAPPING)) self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_add_label_appends_to_issue(self, mock_api, _auth): def test_add_label_appends_to_issue(self, mock_api, _get_all, _auth):
existing = [_make_label("chore", 5)]
def se(method, path, auth, payload=None): def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "POST": if method == "POST":
return [{"name": "chore"}] return [{"name": "chore"}]
return None return None
@@ -212,19 +205,21 @@ class TestModes(unittest.TestCase):
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list)) self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_add_label_unknown_makes_no_write(self, mock_api, _auth): def test_add_label_unknown_makes_no_write(self, mock_api, _get_all, _auth):
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
manage_labels.main(["--add-label", "42", "ghost"]) manage_labels.main(["--add-label", "42", "ghost"])
# Only the GET label lookup; no POST/PUT for an undefined label. # No write via api() for an undefined label.
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)) self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
or len(mock_api.call_args_list) == 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
@patch("manage_labels.api") @patch("manage_labels.api")
def test_add_label_dry_makes_no_write(self, mock_api, _auth): def test_add_label_dry_makes_no_write(self, mock_api, _get_all, _auth):
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
manage_labels.main(["--dry", "--add-label", "42", "chore"]) manage_labels.main(["--dry", "--add-label", "42", "chore"])
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)) self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
or len(mock_api.call_args_list) == 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api") @patch("manage_labels.api")
+19 -3
View File
@@ -5,6 +5,7 @@ the MCP protocol) with mocked API responses.
""" """
import json import json
import os import os
import shutil
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -3811,7 +3812,15 @@ class TestIssueLocking(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @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): 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"] 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.save_lock_file(
issue_lock_store.lock_file_path( issue_lock_store.lock_file_path(
remote="prgs", remote="prgs",
@@ -3825,15 +3834,22 @@ class TestIssueLocking(unittest.TestCase):
"remote": "prgs", "remote": "prgs",
"org": "Scaled-Tech-Consulting", "org": "Scaled-Tech-Consulting",
"repo": prgs_repo, "repo": prgs_repo,
"worktree_path": "/tmp/other-worktree", "worktree_path": sticky_worktree,
"session_pid": os.getpid(),
"pid": os.getpid(),
"work_lease": { "work_lease": {
"operation_type": "author_issue_work", "operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z", "expires_at": "2000-01-01T00:00:00Z",
}, },
}, },
) )
with self.assertRaises(RuntimeError) as ctx: with patch.object(issue_lock_store, "is_process_alive", return_value=True):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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)) self.assertIn("Recovery review is required before takeover", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_get_all", return_value=[])
+15 -24
View File
@@ -64,54 +64,45 @@ class TestMarkIssueCLI(unittest.TestCase):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
mark_issue.main(["10", "bogus_action"]) mark_issue.main(["10", "bogus_action"])
@patch("mark_issue.api_get_all", return_value=[{"id": 101, "name": "status:in-progress"}])
@patch("mark_issue.api_request") @patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH) @patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_start(self, _auth, mock_api): def test_successful_start(self, _auth, mock_api, mock_get_all):
# First call is GET labels, second is POST label mock_api.return_value = [{"name": "status:in-progress"}]
mock_api.side_effect = [
[{"id": 101, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = mark_issue.main(["15", "start"]) rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2) mock_get_all.assert_called()
self.assertIn("/labels", mock_get_all.call_args[0][0])
# Verify GET labels call
get_call = mock_api.call_args_list[0]
self.assertEqual(get_call[0][0], "GET")
self.assertIn("/labels?limit=100", get_call[0][1])
# Verify POST labels call # Verify POST labels call
post_call = mock_api.call_args_list[1] post_call = mock_api.call_args_list[0]
self.assertEqual(post_call[0][0], "POST") self.assertEqual(post_call[0][0], "POST")
self.assertIn("/issues/15/labels", post_call[0][1]) self.assertIn("/issues/15/labels", post_call[0][1])
self.assertEqual(post_call[0][3], {"labels": [101]}) self.assertEqual(post_call[0][3], {"labels": [101]})
@patch("mark_issue.api_get_all", return_value=[{"id": 101, "name": "status:in-progress"}])
@patch("mark_issue.api_request") @patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH) @patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_done(self, _auth, mock_api): def test_successful_done(self, _auth, mock_api, _get_all):
# First call is GET labels, second is DELETE label mock_api.return_value = None
mock_api.side_effect = [
[{"id": 101, "name": "status:in-progress"}],
None,
]
rc = mark_issue.main(["15", "done"]) rc = mark_issue.main(["15", "done"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2) self.assertEqual(mock_api.call_count, 1)
# Verify DELETE labels call # Verify DELETE labels call
delete_call = mock_api.call_args_list[1] delete_call = mock_api.call_args_list[0]
self.assertEqual(delete_call[0][0], "DELETE") self.assertEqual(delete_call[0][0], "DELETE")
self.assertIn("/issues/15/labels/101", delete_call[0][1]) self.assertIn("/issues/15/labels/101", delete_call[0][1])
@patch("mark_issue.api_get_all", return_value=[{"id": 1, "name": "bug"}])
@patch("mark_issue.api_request") @patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH) @patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_label_not_found(self, _auth, mock_api): def test_label_not_found(self, _auth, mock_api, _get_all):
# GET labels returns no status:in-progress label # Paginated inventory returns no status:in-progress label
mock_api.return_value = [{"id": 1, "name": "bug"}]
rc = mark_issue.main(["15", "start"]) rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
mock_api.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
+318
View File
@@ -0,0 +1,318 @@
"""#627: paginated repository-label resolution for gitea_set_issue_labels.
Reproduces the post-merge #601 reconciliation failure where later-page
labels (e.g. type:feature, workflow-hardening) were falsely rejected as
nonexistent because inventory used a single-page GET labels?limit=100.
"""
from __future__ import annotations
import os
import sys
import unittest
from unittest.mock import patch, call
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import gitea_auth
FAKE_AUTH = "token test-token"
PAGE_SIZE = 50 # Gitea effective max; see gitea_auth.api_get_all
def _lb(name: str, lid: int) -> dict:
return {"id": lid, "name": name, "color": "000000"}
def _pages(labels: list[dict], page_size: int = PAGE_SIZE) -> list[list[dict]]:
if not labels:
return [[]]
pages = []
for i in range(0, len(labels), page_size):
pages.append(labels[i : i + page_size])
return pages
class TestRepoLabelIdMapPagination(unittest.TestCase):
"""_repo_label_id_map must use api_get_all (all pages)."""
@patch("mcp_server.api_get_all")
def test_fewer_than_one_page(self, mock_all):
labels = [_lb(f"l{i}", i) for i in range(3)]
mock_all.return_value = labels
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(m, {"l0": 0, "l1": 1, "l2": 2})
mock_all.assert_called_once()
self.assertIn("/labels", mock_all.call_args[0][0])
self.assertNotIn("limit=100", mock_all.call_args[0][0])
@patch("mcp_server.api_get_all")
def test_exactly_one_full_page(self, mock_all):
labels = [_lb(f"l{i:03d}", i) for i in range(PAGE_SIZE)]
mock_all.return_value = labels
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(len(m), PAGE_SIZE)
self.assertEqual(m["l000"], 0)
self.assertEqual(m[f"l{PAGE_SIZE - 1:03d}"], PAGE_SIZE - 1)
@patch("mcp_server.api_get_all")
def test_more_than_one_page_includes_later_labels(self, mock_all):
# Page 1: 50 early labels; page 2: type:feature + workflow-hardening (#601 style)
early = [_lb(f"early-{i:02d}", i) for i in range(PAGE_SIZE)]
late = [
_lb("type:feature", 1001),
_lb("workflow-hardening", 1002),
]
mock_all.return_value = early + late
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(len(m), PAGE_SIZE + 2)
self.assertEqual(m["type:feature"], 1001)
self.assertEqual(m["workflow-hardening"], 1002)
@patch("mcp_server.api_get_all")
def test_duplicate_names_keep_first_seen_id(self, mock_all):
mock_all.return_value = [
_lb("type:feature", 103),
_lb("type:feature", 130), # duplicate id later
_lb("workflow-hardening", 105),
_lb("workflow-hardening", 128),
]
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(m["type:feature"], 103)
self.assertEqual(m["workflow-hardening"], 105)
@patch("mcp_server.api_get_all", return_value={"not": "a list"})
def test_non_list_inventory_fails_closed(self, _all):
with self.assertRaises(RuntimeError) as ctx:
mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertIn("expected a list", str(ctx.exception).lower())
@patch("mcp_server.api_get_all", side_effect=RuntimeError("page 2 failed: connection reset"))
def test_later_page_failure_propagates(self, _all):
with self.assertRaises(RuntimeError) as ctx:
mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertIn("page 2 failed", str(ctx.exception))
class TestSetIssueLabelsPagination(unittest.TestCase):
"""gitea_set_issue_labels full-set replacement with multi-page inventory."""
def setUp(self):
self._remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.example.com", "org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools"}},
)
self._remotes.start()
# Allow mutation path without full preflight stack when possible
self._preflight = patch(
"mcp_server.verify_preflight_purity", return_value=None
)
self._preflight.start()
self._perm = patch(
"mcp_server._profile_permission_block", return_value=None
)
self._perm.start()
self._auth = patch(
"mcp_server._auth", return_value=FAKE_AUTH
)
self._auth.start()
self._audited = patch("mcp_server._audited")
mock_aud = self._audited.start()
mock_aud.return_value.__enter__ = lambda s: None
mock_aud.return_value.__exit__ = lambda s, *a: None
def tearDown(self):
self._remotes.stop()
self._preflight.stop()
self._perm.stop()
self._auth.stop()
self._audited.stop()
def _inventory_early_and_late(self) -> list[dict]:
early = [_lb(f"alpha-{i:02d}", i + 1) for i in range(PAGE_SIZE)]
late = [
_lb("type:feature", 9001),
_lb("workflow-hardening", 9002),
_lb("status:ready", 9003),
_lb("anti-stomp", 9004),
_lb("leases", 9005),
_lb("recovery", 9006),
]
return early + late
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_requested_labels_split_across_pages(self, mock_all, mock_req):
inv = self._inventory_early_and_late()
mock_all.return_value = inv
requested = ["alpha-00", "type:feature", "workflow-hardening"]
mock_req.return_value = [_lb(n, inv_i["id"]) for n in requested
for inv_i in inv if inv_i["name"] == n]
res = mcp_server.gitea_set_issue_labels(
issue_number=601,
labels=requested,
remote="prgs",
)
self.assertEqual({lb["name"] for lb in res}, set(requested))
# PUT must use ids from both pages
put = mock_req.call_args
self.assertEqual(put[0][0], "PUT")
payload_ids = put[0][3]["labels"]
self.assertEqual(payload_ids, [1, 9001, 9002])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_missing_requested_label_rejected_before_put(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)]
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9,
labels=["bug", "does-not-exist"],
remote="prgs",
)
self.assertIn("do not exist", str(ctx.exception))
self.assertIn("does-not-exist", str(ctx.exception))
mock_req.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_complete_set_preserves_later_page_labels(self, mock_all, mock_req):
inv = self._inventory_early_and_late()
mock_all.return_value = inv
# Full-set replacement removing only status:pr-open style stale label:
# keep later-page feature labels (the #601 reconciliation shape).
requested = [
"anti-stomp",
"leases",
"recovery",
"type:feature",
"workflow-hardening",
]
mock_req.return_value = [_lb(n, next(x["id"] for x in inv if x["name"] == n))
for n in requested]
res = mcp_server.gitea_set_issue_labels(
issue_number=601, labels=requested, remote="prgs"
)
self.assertEqual([lb["name"] for lb in res], requested)
payload_ids = mock_req.call_args[0][3]["labels"]
self.assertEqual(payload_ids, [9004, 9005, 9006, 9001, 9002])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_issue_601_regression_later_page_names_accepted(self, mock_all, mock_req):
"""Regression: #601 reconciler rejected type:feature + workflow-hardening.
Single-page inventory would omit them; paginated inventory must accept.
"""
early = [_lb(f"page1-{i:02d}", i + 1) for i in range(PAGE_SIZE)]
# Exactly the labels from the #601 residual set (minus status:pr-open)
late = [
_lb("type:feature", 103),
_lb("workflow-hardening", 105),
_lb("anti-stomp", 106),
_lb("leases", 109),
_lb("recovery", 110),
]
mock_all.return_value = early + late
requested = [
"anti-stomp",
"leases",
"recovery",
"type:feature",
"workflow-hardening",
]
mock_req.return_value = [
_lb(n, next(x["id"] for x in late if x["name"] == n)) for n in requested
]
res = mcp_server.gitea_set_issue_labels(
issue_number=601, labels=requested, remote="prgs"
)
names = {lb["name"] for lb in res}
self.assertEqual(names, set(requested))
self.assertNotIn("status:pr-open", names)
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_duplicate_label_ids_resolve_deterministically(self, mock_all, mock_req):
mock_all.return_value = [
_lb("type:feature", 103),
_lb("type:feature", 130),
_lb("workflow-hardening", 105),
_lb("workflow-hardening", 128),
]
requested = ["type:feature", "workflow-hardening"]
mock_req.return_value = [_lb("type:feature", 103), _lb("workflow-hardening", 105)]
mcp_server.gitea_set_issue_labels(
issue_number=1, labels=requested, remote="prgs"
)
self.assertEqual(mock_req.call_args[0][3]["labels"], [103, 105])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_post_mutation_mismatch_fails_closed(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)]
# Server returns incomplete set → verification must fail
mock_req.return_value = [_lb("bug", 1)]
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9,
labels=["bug", "status:ready"],
remote="prgs",
)
self.assertIn("Post-mutation label verification failed", str(ctx.exception))
self.assertIn("status:ready", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", side_effect=RuntimeError("Gitea 502 on page 2"))
def test_pagination_failure_fails_closed_before_put(self, _all, mock_req):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9, labels=["bug"], remote="prgs"
)
self.assertIn("page 2", str(ctx.exception))
mock_req.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_empty_label_set_clears_all(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1)]
mock_req.return_value = []
res = mcp_server.gitea_set_issue_labels(
issue_number=9, labels=[], remote="prgs"
)
self.assertEqual(res, [])
self.assertEqual(mock_req.call_args[0][3]["labels"], [])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_does_not_call_single_page_limit_100(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1)]
mock_req.return_value = [_lb("bug", 1)]
mcp_server.gitea_set_issue_labels(
issue_number=9, labels=["bug"], remote="prgs"
)
for c in mock_req.call_args_list:
url = c[0][1] if len(c[0]) > 1 else ""
self.assertNotIn("labels?limit=100", str(url))
mock_all.assert_called()
class TestApiGetAllPageCapDocumentsGiteaLimit(unittest.TestCase):
"""Sanity: api_get_all clamps page_size to 50 (root cause of limit=100 trap)."""
@patch("gitea_auth.api_request")
def test_page_size_clamped_to_fifty(self, mock_req):
mock_req.return_value = []
gitea_auth.api_get_all("https://gitea.example/api/v1/repos/o/r/labels",
FAKE_AUTH, page_size=100)
# First (only) call must use limit=50, not 100
url = mock_req.call_args[0][1]
self.assertIn("limit=50", url)
self.assertNotIn("limit=100", url)
if __name__ == "__main__":
unittest.main()