From 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Wed, 22 Jul 2026 17:56:28 -0500 Subject: [PATCH 01/19] feat(webui): evolve application shell for Phase 1 console IA (Closes #638) Introduce a single nav-config module (webui/nav.py) driving grouped navigation across the epic #631 Phase 1 information architecture: Health, Traffic, Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and Insights (placeholder). The shell header now carries a read-only environment badge (local/remote from WEBUI_HOST), a mode: read-only badge, and a Docs link. Home summarizes the console purpose, lists the Phase 1 surfaces by group, and links the MVP legacy pages. Not-yet-implemented surfaces (/sessions, /inventory, /timeline, /policy, /insights) resolve to graceful read-only stub pages rather than 404s; their backing views land in later child issues of #631 (inventory surfaces backed by #636). Mutating methods on stub routes still fail closed with read-only-mvp. No privileged action controls are added. Adds tests/test_webui_shell.py covering nav groups, badge presence, environment classification, docs link, stub routes (200 + read-only), and home content. Updates docs/webui-local-dev.md with the new routes and a Phase 1 shell section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 25 +++++++ tests/test_webui_shell.py | 135 ++++++++++++++++++++++++++++++++++++++ webui/app.py | 65 ++++++++++++++---- webui/layout.py | 112 ++++++++++++++++++++++++++----- webui/nav.py | 111 +++++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 tests/test_webui_shell.py create mode 100644 webui/nav.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index a92e509..4e81498 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -67,6 +67,11 @@ that govern when a write path may open (#632, epic #631). | `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) | | `/leases` | Lease and collision visibility (#433) | | `/api/leases` | JSON lease/collision export | +| `/sessions` | Phase 1 shell stub — session inventory (backed by #636) | +| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) | +| `/timeline` | Phase 1 shell stub — workflow event timeline | +| `/policy` | Phase 1 shell stub — capability/role policy placeholder | +| `/insights` | Phase 1 shell stub — operational insights placeholder | Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with `read-only-mvp`, except `/audit` and `/api/audit` which accept POST for @@ -147,6 +152,26 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## Application shell — Phase 1 (#638) + +The console shell (`webui/layout.py`) renders a grouped navigation driven by a +single nav-config module, `webui/nav.py`. Nav groups follow the epic #631 +Phase 1 information architecture: **Health, Traffic, Runtime/Sessions, +Projects, Inventory, Timeline, Policy** (placeholder), and **Insights** +(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one +place so the layout and the route table cannot drift. + +The header carries two read-only status badges — an **environment** badge +(`local` for loopback binds, `remote` otherwise, derived from `WEBUI_HOST`) and +a **mode: read-only** badge — plus a **Docs** link to this document. No +privileged action controls are present in the Phase 1 shell. + +Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`, +`/policy`, `/insights`) resolve to graceful read-only stub pages instead of +404s; their backing views land in later child issues of #631 (the inventory +surfaces are backed by #636). Mutating methods on stub routes still fail closed +with `read-only-mvp`. + ## Deployment boundary (#435) MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** diff --git a/tests/test_webui_shell.py b/tests/test_webui_shell.py new file mode 100644 index 0000000..e3c117f --- /dev/null +++ b/tests/test_webui_shell.py @@ -0,0 +1,135 @@ +"""Tests for the Phase 1 operator console application shell (#638).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.routing import Route +from starlette.testclient import TestClient + +from webui import layout +from webui.app import create_app +from webui.nav import NAV_GROUPS, STUB_PAGES, nav_hrefs + + +class TestShellNav(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_nav_group_labels_present(self): + text = self.client.get("/").text + for group in NAV_GROUPS: + with self.subTest(group=group.label): + self.assertIn(f">{group.label}<", text) + + def test_phase1_group_labels_cover_expected_ia(self): + labels = {group.label for group in NAV_GROUPS} + for expected in ( + "Health", + "Traffic", + "Runtime/Sessions", + "Projects", + "Inventory", + "Timeline", + "Policy", + "Insights", + ): + with self.subTest(label=expected): + self.assertIn(expected, labels) + + def test_every_nav_href_resolves_to_a_get_route(self): + app = create_app() + get_paths = { + route.path + for route in app.routes + if isinstance(route, Route) and "GET" in route.methods + } + for href in nav_hrefs(): + with self.subTest(href=href): + self.assertIn(href, get_paths, f"nav href {href} has no GET route") + + def test_legacy_hrefs_still_navigable(self): + text = self.client.get("/").text + for href in ("/queue", "/projects", "/prompts", "/runtime", + "/audit", "/worktrees", "/leases", "/actions"): + with self.subTest(href=href): + self.assertIn(f'href="{href}"', text) + + +class TestShellBadges(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_mode_badge_present(self): + self.assertIn("mode: read-only", self.client.get("/").text) + + def test_environment_badge_present(self): + self.assertIn("env:", self.client.get("/").text) + + def test_default_environment_is_local(self): + self.assertEqual(layout.environment_label(), "local") + + def test_remote_bind_reports_remote_environment(self): + import os + + prior = os.environ.get("WEBUI_HOST") + os.environ["WEBUI_HOST"] = "10.0.0.5" + try: + self.assertEqual(layout.environment_label(), "remote") + finally: + if prior is None: + os.environ.pop("WEBUI_HOST", None) + else: + os.environ["WEBUI_HOST"] = prior + + def test_docs_link_present(self): + text = self.client.get("/").text + self.assertIn(layout.DOCS_URL, text) + self.assertIn(">Docs<", text) + + +class TestShellStubs(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_stub_routes_render_200(self): + for path, (title, _desc) in STUB_PAGES.items(): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200, path) + self.assertIn(title, response.text) + self.assertIn("placeholder", response.text) + + def test_stub_routes_are_read_only(self): + for path in STUB_PAGES: + with self.subTest(path=path): + response = self.client.post(path) + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json()["error"], "read-only-mvp") + + def test_stub_pages_carry_nav_and_badges(self): + response = self.client.get("/inventory") + self.assertIn("mode: read-only", response.text) + self.assertIn('href="/queue"', response.text) + + +class TestShellHome(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_home_summarizes_console(self): + text = self.client.get("/").text + self.assertIn("Operator console", text) + self.assertIn("Phase 1", text) + + def test_home_links_legacy_pages(self): + text = self.client.get("/").text + self.assertIn("MVP legacy pages", text) + for href in ("/queue", "/audit", "/leases"): + with self.subTest(href=href): + self.assertIn(f'href="{href}"', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/app.py b/webui/app.py index d0f832b..eba1a8a 100644 --- a/webui/app.py +++ b/webui/app.py @@ -11,6 +11,7 @@ from starlette.routing import Route from webui.deployment_boundary import deployment_snapshot from webui.layout import render_page +from webui.nav import NAV_GROUPS, STUB_PAGES from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list from webui.prompt_library import find_prompt, library_to_dict @@ -43,24 +44,62 @@ def _stub_page(title: str, description: str) -> HTMLResponse: return HTMLResponse(render_page(title=title, body_html=body)) +_LEGACY_PAGES = ( + ("/queue", "Queue", "live PR and issue dashboard (#429)"), + ("/projects", "Projects", "registry and onboarding (#427)"), + ("/prompts", "Prompts", "canonical workflow prompt library (#428)"), + ("/runtime", "Runtime", "MCP health and stale-runtime detection (#430)"), + ("/audit", "Audit", "final-report paste and validator preview (#431)"), + ("/worktrees", "Worktrees", "branch hygiene dashboard (#432)"), + ("/leases", "Leases", "collision and lease visibility (#433)"), + ("/actions", "Actions", "gated write-action framework (#434)"), +) + + +def _render_home_nav_groups() -> str: + groups = [] + for group in NAV_GROUPS: + items = "".join( + f'
  • {item.label}' + + ("" if item.status == "live" else " (stub)") + + "
  • " + for item in group.items + ) + groups.append(f"

    {group.label}

    ") + return "".join(groups) + + async def home(_request: Request) -> HTMLResponse: + legacy = "".join( + f"
  • {label} — {desc} " + f'({href})
  • ' + for href, label, desc in _LEGACY_PAGES + ) body = ( "

    Operator console

    " - "

    Local entry point for MCP Control Plane operational views.

    " - "" + "

    Read-only home for the MCP Control Plane Phase 1 operator console. " + "Gitea, MCP capability gates, and canonical workflows remain the source " + "of truth; this console never mutates them.

    " + "

    Phase 1 surfaces

    " + + _render_home_nav_groups() + + "

    MVP legacy pages

    " + "" ) return HTMLResponse(render_page(title="Home", body_html=body)) +async def phase_stub(request: Request) -> HTMLResponse: + """Graceful read-only placeholder for a not-yet-implemented Phase 1 surface.""" + title, description = STUB_PAGES[request.url.path] + body = ( + f"

    {title}

    " + f'

    {description}

    ' + "

    Phase 1 shell placeholder — no write actions. Tracked under " + "epic #631.

    " + ) + return HTMLResponse(render_page(title=title, body_html=body)) + + async def health(_request: Request) -> JSONResponse: bind_host = _request.app.state.webui_bind_host return JSONResponse({ @@ -291,6 +330,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette: methods=["POST"], ), Route("/api/leases", api_leases, methods=["GET"]), + *[ + Route(path, phase_stub, methods=["GET"]) + for path in STUB_PAGES + ], ], exception_handlers={405: method_not_allowed}, ) diff --git a/webui/layout.py b/webui/layout.py index 47bedc1..4d62eca 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -2,28 +2,66 @@ from __future__ import annotations -NAV_ITEMS = ( - ("/", "Home"), - ("/queue", "Queue"), - ("/projects", "Projects"), - ("/prompts", "Prompts"), - ("/runtime", "Runtime"), - ("/audit", "Audit"), - ("/worktrees", "Worktrees"), - ("/leases", "Leases"), - ("/actions", "Actions"), -) +import os + +from webui.nav import NAV_GROUPS MVP_NOTICE = ( "Read-only MVP — Gitea, MCP tools, and canonical workflows remain the " "source of truth. No mutation endpoints." ) +# Canonical docs entry point surfaced from the shell header (#638). +DOCS_URL = ( + "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/src/branch/" + "master/docs/webui-local-dev.md" +) + +_LOCAL_HOSTS = frozenset({"", "127.0.0.1", "localhost", "::1"}) + + +def environment_label() -> str: + """Classify the serving environment as ``local`` or ``remote`` (#638). + + Derived from the same ``WEBUI_HOST`` default the app binds to; loopback + hosts are ``local``, anything else is ``remote``. Read-only signal only. + """ + host = (os.environ.get("WEBUI_HOST", "127.0.0.1") or "").strip().lower() + return "local" if host in _LOCAL_HOSTS else "remote" + + +def _render_nav() -> str: + groups_html = [] + for group in NAV_GROUPS: + links = "".join( + f'{item.label}' + for item in group.items + ) + groups_html.append( + '" + ) + return "".join(groups_html) + + +def _render_badges() -> str: + env = environment_label() + return ( + '
    ' + f'env: {env}' + 'mode: read-only' + f'Docs' + "
    " + ) + def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: - nav_links = "".join( - f'{label}' for href, label in NAV_ITEMS - ) + nav_links = _render_nav() + header_badges = _render_badges() return f""" @@ -53,21 +91,58 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: padding: 0.75rem 1.25rem; }} header h1 {{ - margin: 0 0 0.5rem; + margin: 0; font-size: 1.1rem; font-weight: 600; }} + .header-top {{ + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.6rem; + }} + .header-badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.4rem; }} + .env-badge.env-local {{ color: #8fd19e; border-color: #3d6b4a; }} + .env-badge.env-remote {{ color: #e0c27a; border-color: #6b5730; }} + .mode-badge {{ color: #9ec8f0; border-color: #3d5f7a; }} + a.docs-link {{ + color: var(--accent); + border-color: var(--accent); + text-decoration: none; + text-transform: none; + }} + a.docs-link:hover {{ filter: brightness(1.12); }} nav {{ display: flex; flex-wrap: wrap; - gap: 0.75rem 1rem; + gap: 0.5rem 1.25rem; }} + .nav-group {{ + display: flex; + flex-direction: column; + gap: 0.15rem; + }} + .nav-group-label {{ + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + }} + .nav-group-links {{ display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }} nav a {{ color: var(--accent); text-decoration: none; font-size: 0.9rem; }} nav a:hover {{ text-decoration: underline; }} + nav a.nav-stub {{ color: var(--muted); }} + nav a.nav-stub::after {{ + content: " ·stub"; + font-size: 0.7rem; + color: var(--muted); + }} main {{ max-width: 52rem; margin: 0 auto; @@ -166,7 +241,10 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
    -

    MCP Control Plane

    +
    +

    MCP Control Plane

    + {header_badges} +
    diff --git a/webui/nav.py b/webui/nav.py new file mode 100644 index 0000000..edb128c --- /dev/null +++ b/webui/nav.py @@ -0,0 +1,111 @@ +"""Navigation IA for the Phase 1 operator console shell (#638). + +Single source of truth for the console navigation so ``webui/layout.py`` and +the ``webui/app.py`` route table stay aligned with epic #631. Read-only: every +destination is a GET view or a Phase 1 placeholder. No mutation links. + +Nav groups follow the #631 Phase 1 information architecture: Health, Traffic, +Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and +Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and +backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder +instead of a 404. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class NavItem: + """A single navigation destination. + + ``status`` is ``"live"`` for implemented views and ``"stub"`` for Phase 1 + placeholders whose backing view lands in a later child issue. + """ + + href: str + label: str + status: str = "live" + + +@dataclass(frozen=True) +class NavGroup: + label: str + items: tuple[NavItem, ...] + + +NAV_GROUPS: tuple[NavGroup, ...] = ( + NavGroup("Health", ( + NavItem("/health", "Liveness"), + )), + NavGroup("Traffic", ( + NavItem("/queue", "Queue"), + NavItem("/leases", "Leases"), + NavItem("/actions", "Actions"), + )), + NavGroup("Runtime/Sessions", ( + NavItem("/runtime", "Runtime health"), + NavItem("/sessions", "Sessions", "stub"), + )), + NavGroup("Projects", ( + NavItem("/projects", "Projects"), + )), + NavGroup("Inventory", ( + NavItem("/inventory", "Inventory", "stub"), + NavItem("/worktrees", "Worktrees"), + )), + NavGroup("Timeline", ( + NavItem("/timeline", "Timeline", "stub"), + )), + NavGroup("Policy", ( + NavItem("/policy", "Policy", "stub"), + NavItem("/prompts", "Prompts"), + )), + NavGroup("Insights", ( + NavItem("/insights", "Insights", "stub"), + NavItem("/audit", "Audit"), + )), +) + + +# Phase 1 placeholder destinations whose backing views land in later child +# issues of epic #631. Each maps a path to (title, description). Routes are +# registered so nav links resolve to a graceful, read-only stub page. +STUB_PAGES: dict[str, tuple[str, str]] = { + "/sessions": ( + "Sessions", + "Active session, capability, and role inventory. Backed by the unified " + "inventory API (#636) once it lands.", + ), + "/inventory": ( + "Inventory", + "Unified sessions, leases, locks, namespaces, and worktree inventory. " + "Backed by the Phase 1 inventory API (#636).", + ), + "/timeline": ( + "Timeline", + "Workflow event timeline across issues and PRs. A later Phase 1 surface.", + ), + "/policy": ( + "Policy", + "Capability and role policy surface. Placeholder until a later phase.", + ), + "/insights": ( + "Insights", + "Aggregate operational insights and trends. Placeholder until a later " + "phase.", + ), +} + + +def iter_nav_items(): + """Yield every ``NavItem`` across all groups in declared order.""" + for group in NAV_GROUPS: + for item in group.items: + yield item + + +def nav_hrefs() -> tuple[str, ...]: + """Return every navigation href in declared order.""" + return tuple(item.href for item in iter_nav_items()) From 8a63476787d7616030c43152476ab95fc09988d6 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 03:21:07 -0400 Subject: [PATCH 02/19] fix: conflict-fix lease lifecycle chain termination and TTL handling (Closes #847, Refs #842) --- pr_work_lease.py | 53 ++++++++++++- tests/test_pr_work_lease.py | 153 ++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 2 deletions(-) diff --git a/pr_work_lease.py b/pr_work_lease.py index 2fb6876..b877f10 100644 --- a/pr_work_lease.py +++ b/pr_work_lease.py @@ -228,25 +228,74 @@ def find_active_reviewer_lease( return None +def _conflict_fix_chain_key(lease: dict) -> tuple | None: + """Identity of the lease chain a conflict-fix marker belongs to (#842). + + Keyed by PR number, profile, head_before, and branch. Returns None when any + required component (pr_number, profile, head_before) is missing or malformed. + """ + raw = lease.get("raw_fields") or {} + pr_number = lease.get("pr_number") + profile = (lease.get("profile") or "").strip().lower() + head_before = lease.get("head_before") + branch = (lease.get("branch") or raw.get("branch") or "").strip() + if not (pr_number and profile and head_before): + return None + return (pr_number, profile, head_before, branch) + + +def _conflict_fix_chain_matches(key1: tuple, key2: tuple) -> bool: + """True when two conflict-fix chain keys refer to the same lease chain.""" + pr1, profile1, head1, branch1 = key1 + pr2, profile2, head2, branch2 = key2 + if pr1 != pr2 or profile1 != profile2 or head1 != head2: + return False + if branch1 and branch2 and branch1 != branch2: + return False + return True + + +def _conflict_fix_chain_terminated_after(entries: list[dict], index: int) -> bool: + """True when a later marker terminates the conflict-fix chain of ``entries[index]``. + + Append-only newest-wins: a terminal marker (phase=released/blocked/done) + ends only its matching claim chain (#842). + """ + key = _conflict_fix_chain_key(entries[index]) + if key is None: + return False + for later in entries[index + 1:]: + phase = (later.get("phase") or "").strip().lower() + if phase not in _TERMINAL_CONFLICT_FIX_PHASES: + continue + later_key = _conflict_fix_chain_key(later) + if later_key and _conflict_fix_chain_matches(key, later_key): + return True + return False + + def find_active_conflict_fix_lease( comments: list[dict], *, pr_number: int, now: datetime | None = None, ) -> dict[str, Any] | None: - """Return the newest unexpired conflict-fix lease for *pr_number*, if any.""" + """Return the newest unexpired, non-terminated conflict-fix lease for *pr_number*, if any.""" now = now or datetime.now(timezone.utc) candidates = [ entry for entry in _comment_entries(comments, pr_number=pr_number) if entry.get("lease_kind") == "conflict_fix" ] - for lease in reversed(candidates): + for index in range(len(candidates) - 1, -1, -1): + lease = candidates[index] if _lease_expired(lease, now=now): continue phase = (lease.get("phase") or "").strip().lower() if phase in _TERMINAL_CONFLICT_FIX_PHASES: continue if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase: + if _conflict_fix_chain_terminated_after(candidates, index): + continue return lease return None diff --git a/tests/test_pr_work_lease.py b/tests/test_pr_work_lease.py index 8dbcb33..ddbf2cc 100644 --- a/tests/test_pr_work_lease.py +++ b/tests/test_pr_work_lease.py @@ -19,6 +19,7 @@ from pr_work_lease import ( # noqa: E402 assess_reviewer_mutation_blocked, assess_reviewer_stale_head_final_report, format_conflict_fix_lease_body, + find_active_conflict_fix_lease, parse_conflict_fix_lease_comment, parse_reviewer_lease_comment, ) @@ -203,5 +204,157 @@ class TestFormatLease(unittest.TestCase): self.assertEqual(parsed["pr_number"], 376) +class TestConflictFixLeaseLifecycle(unittest.TestCase): + def test_claim_followed_by_matching_release(self): + claim_body = _conflict_fix_body(phase="claimed", worktree="branches/fix-376") + expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z") + release_body = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "branch: feat/fix-376", + "worktree: branches/fix-376", + "profile: prgs-author", + "phase: released", + f"head_before: {HEAD_A}", + f"head_after: {HEAD_B}", + f"expires_at: {expires}", + ]) + comments = [{"body": claim_body}, {"body": release_body}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNone(lease) + + def test_expired_claim_without_release(self): + past_expires = (NOW - timedelta(minutes=10)).isoformat().replace("+00:00", "Z") + claim_body = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "phase: claimed", + f"head_before: {HEAD_A}", + f"expires_at: {past_expires}", + "profile: prgs-author", + ]) + comments = [{"body": claim_body}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNone(lease) + + def test_mismatched_release_different_head(self): + claim_body = _conflict_fix_body(phase="claimed", worktree="branches/fix-376") + expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z") + release_body = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "profile: prgs-author", + "phase: released", + f"head_before: {HEAD_B}", + f"expires_at: {expires}", + ]) + comments = [{"body": claim_body}, {"body": release_body}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNotNone(lease) + self.assertEqual(lease["phase"], "claimed") + + def test_mismatched_release_different_branch(self): + claim_body = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "branch: feat/branch-A", + "phase: claimed", + f"head_before: {HEAD_A}", + f"expires_at: {(NOW + timedelta(minutes=60)).isoformat().replace('+00:00', 'Z')}", + "profile: prgs-author", + ]) + release_body = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "branch: feat/branch-B", + "phase: released", + f"head_before: {HEAD_A}", + f"expires_at: {(NOW + timedelta(minutes=60)).isoformat().replace('+00:00', 'Z')}", + "profile: prgs-author", + ]) + comments = [{"body": claim_body}, {"body": release_body}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNotNone(lease) + self.assertEqual(lease["phase"], "claimed") + + def test_release_followed_by_newer_claim(self): + claim_1 = _conflict_fix_body(phase="claimed", worktree="branches/fix-376") + expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z") + release_1 = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "profile: prgs-author", + "phase: released", + f"head_before: {HEAD_A}", + f"head_after: {HEAD_B}", + f"expires_at: {expires}", + ]) + claim_2 = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "profile: prgs-author", + "phase: claimed", + f"head_before: {HEAD_B}", + f"expires_at: {expires}", + ]) + comments = [{"body": claim_1}, {"body": release_1}, {"body": claim_2}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNotNone(lease) + self.assertEqual(lease["head_before"], HEAD_B) + + def test_malformed_or_ambiguous_markers(self): + malformed_release = "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + "phase: released", + # missing head_before and profile + ]) + claim_body = _conflict_fix_body(phase="claimed") + comments = [{"body": claim_body}, {"body": malformed_release}] + lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW) + self.assertIsNotNone(lease) + + def test_pr818_historical_sequence(self): + comment_14696 = "\n".join([ + "", + "pr: #818", + "branch: feat/issue-638-webui-app-shell-phase1", + "worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/issue-638-webui-app-shell-phase1", + "profile: prgs-author", + "session_id: unknown", + "phase: claimed", + "head_before: 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb", + "expires_at: 2026-07-23T07:12:13Z", + "reviewer_active: no", + ]) + comment_14730 = "\n".join([ + "", + "pr: #818", + "branch: feat/issue-638-webui-app-shell-phase1", + "worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/issue-638-webui-app-shell-phase1", + "profile: prgs-author", + "session_id: prgs-author-61241-e5129c60", + "phase: released", + "head_before: 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb", + "head_after: 64b6eb5d5402663098de5ded3b0617cc3b3df98f", + "expires_at: 2026-07-23T06:05:00Z", + "reviewer_active: no", + ]) + comments = [{"body": comment_14696}, {"body": comment_14730}] + check_now = datetime(2026, 7, 23, 6, 30, tzinfo=timezone.utc) + lease = find_active_conflict_fix_lease(comments, pr_number=818, now=check_now) + self.assertIsNone(lease) + + reviewer_gate = assess_reviewer_mutation_blocked( + pr_number=818, + comments=comments, + reviewed_head_sha="64b6eb5d5402663098de5ded3b0617cc3b3df98f", + live_head_sha="64b6eb5d5402663098de5ded3b0617cc3b3df98f", + mutation="approve", + now=check_now, + ) + self.assertTrue(reviewer_gate["mutation_allowed"]) + + if __name__ == "__main__": unittest.main() \ No newline at end of file From 25bc2a32918d7b068559665022ec2c28a600aff4 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 14:48:46 -0400 Subject: [PATCH 03/19] feat(webui): workflow-event and conversation timeline model (Closes #637) Phase 1 child of the Web Console epic #631. Adds a durable, versioned WorkflowEvent schema with per-source adapters and a read-only query API so operators can browse a unified timeline of workflow events, decisions, tool calls, and handoffs instead of scattered evidence. - webui/timeline.py (new): versioned WorkflowEvent schema; control-plane event adapter and Gitea CTH handoff-comment adapter; read-only mode=ro control-plane reader; conjunctive filter by issue/PR/session; stable (timestamp, source_rank, event_key) ordering; bounded pagination; fail-soft per-source status; redaction at the boundary, fail closed. - webui/app.py: GET /api/v1/timeline read-only route with thread-scoped, fail-soft handoff comment source. - tests/test_webui_timeline.py (new): schema, adapters, redaction of secret-like payloads, filter/sort/pagination, scoped CP reader, fail-soft composition, and API integration. - docs/webui-local-dev.md: timeline route and field-authority notes. Read-only Phase 1; no mutation of historical events; no full chat replay; no unredacted tool-argument storage. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 70 +++++ tests/test_webui_timeline.py | 364 +++++++++++++++++++++++ webui/app.py | 97 +++++++ webui/timeline.py | 539 +++++++++++++++++++++++++++++++++++ 4 files changed, 1070 insertions(+) create mode 100644 tests/test_webui_timeline.py create mode 100644 webui/timeline.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index f5799b2..7f24212 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -212,6 +212,76 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## Workflow-event timeline (#637) + +`GET /api/v1/timeline` is a read-only, versioned aggregation of workflow +events from every available source into one normalised, filterable stream. It +is the model layer for the Phase 1 timeline console view (a later child issue +of #631); this issue ships the schema, adapters, and read API only. + +### Schema (versioned) + +`webui/timeline.py` declares `TIMELINE_SCHEMA_VERSION` (currently `1`) and the +frozen `WorkflowEvent` record. Every response carries `schema_version` so a +consumer can branch on shape. One event: + +```json +{ + "source": "control_plane", + "event_type": "lease.renew", + "event_key": "cp:1421", + "timestamp": "2026-07-23T02:00:00Z", + "actor": null, + "role": null, + "issue_number": 637, + "pr_number": null, + "session_id": null, + "tool_name": null, + "decision": null, + "message": "lease renewed", + "correlation_id": "issue#637", + "evidence_refs": [], + "sensitive": true +} +``` + +`event_key` is stable and unique per source (`cp:`, +`cth:::`), so pagination and dedup are deterministic. + +### Sources and field authority + +| Source | Adapter | Authority | +|---|---|---| +| Control-plane `events` ⋈ `work_items` | `adapt_cp_events` | `event_type`, `message`, `timestamp`, issue/PR scope come from the CP database, read through a `mode=ro` URI (never creates the DB or runs migrations) | +| Gitea Canonical Thread Handoff comments | `adapt_cth_comments` | `actor`, `role` (next owner), `decision`, `evidence_refs`, `timestamp` come from the parsed CTH comment body (`canonical_thread_handoff`) | + +Handoff comments are thread-scoped: they are only read when the request filters +by a single `issue` or `pr`. Otherwise the handoff source reports `not run` +with a reason — it is never rendered as empty-and-healthy. Each source degrades +independently: an unavailable control-plane DB or a failed comment fetch is a +`sources[]` entry with `ok:false` and a `reason`, never a dropped timeline. + +### Query parameters + +`issue`, `pr`, `session` (conjunctive filters); `limit` (default 50, max 500) +and `offset` for pagination; `remote`, `org`, `repo` to override the default +registry-project scope. Events sort ascending by +`(timestamp, source_rank, event_key)`; missing timestamps sort last. + +### Redaction + +Every free-text field (event messages, decision/proof text, roles) is passed +through the console redaction policy (`webui.console_redaction`, backed by +`gitea_audit.redact`) before it leaves the module, failing closed to the +placeholder. No unredacted tool arguments or secrets are ever emitted, and a +generation error never drops raw data to a caller or a log. + +### Tests + +```bash +pytest tests/test_webui_timeline.py -q +``` + ## Tests ```bash diff --git a/tests/test_webui_timeline.py b/tests/test_webui_timeline.py new file mode 100644 index 0000000..35384c0 --- /dev/null +++ b/tests/test_webui_timeline.py @@ -0,0 +1,364 @@ +"""Tests for the workflow-event timeline model and read API (#637). + +Covers the acceptance criteria: versioned schema, adaptation of control-plane +events and Gitea handoff comments, filter by issue/PR/session, redaction of +secret-like payloads, and stable pagination. +""" +import os +import sqlite3 +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +import control_plane_db +from canonical_thread_handoff import format_cth_body +from webui import timeline +from webui.app import create_app + + +def _seed_db(path: str) -> None: + """Create a control-plane DB and seed scoped work_items + events.""" + # Constructing ControlPlaneDB runs the schema migration once. + control_plane_db.ControlPlaneDB(db_path=path) + conn = sqlite3.connect(path) + try: + conn.execute( + "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " + "VALUES (?,?,?,?,?,?,?)", + ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "issue", 637, "open", "2026-07-23T00:00:00Z"), + ) + issue_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " + "VALUES (?,?,?,?,?,?,?)", + ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "pr", 813, "open", "2026-07-23T00:00:00Z"), + ) + pr_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + # A work item for a different repo — must never appear in prgs/Gitea-Tools scope. + conn.execute( + "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " + "VALUES (?,?,?,?,?,?,?)", + ("dadeschools", "Other", "Elsewhere", "issue", 1, "open", "2026-07-23T00:00:00Z"), + ) + other_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + rows = [ + (issue_wid, "allocation", "assigned author work", "2026-07-23T01:00:00Z"), + (issue_wid, "lease.renew", "token=ghs_ABCDEF1234567890abcdef lease renewed", "2026-07-23T02:00:00Z"), + (pr_wid, "pr.opened", "PR opened for review", "2026-07-23T03:00:00Z"), + (other_wid, "allocation", "off-scope event", "2026-07-23T04:00:00Z"), + ] + conn.executemany( + "INSERT INTO events(work_item_id, event_type, message, created_at) VALUES (?,?,?,?)", + rows, + ) + conn.commit() + finally: + conn.close() + + +class TestSchema(unittest.TestCase): + def test_schema_is_versioned(self): + self.assertIsInstance(timeline.TIMELINE_SCHEMA_VERSION, int) + self.assertGreaterEqual(timeline.TIMELINE_SCHEMA_VERSION, 1) + + def test_event_to_dict_shape(self): + ev = timeline.WorkflowEvent( + source=timeline.SOURCE_CONTROL_PLANE, + event_type="allocation", + event_key="cp:1", + timestamp="2026-07-23T01:00:00Z", + issue_number=637, + ) + d = ev.to_dict() + for key in ( + "source", "event_type", "event_key", "timestamp", "actor", "role", + "issue_number", "pr_number", "session_id", "tool_name", "decision", + "message", "correlation_id", "evidence_refs", "sensitive", + ): + self.assertIn(key, d) + self.assertEqual(d["evidence_refs"], []) + + +class TestCpAdapter(unittest.TestCase): + def test_issue_and_pr_mapping(self): + rows = [ + {"event_id": 1, "event_type": "allocation", "message": "x", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 637}, + {"event_id": 2, "event_type": "pr.opened", "message": "y", "created_at": "2026-07-23T02:00:00Z", "kind": "pr", "number": 813}, + ] + events = timeline.adapt_cp_events(rows) + self.assertEqual(len(events), 2) + self.assertEqual(events[0].issue_number, 637) + self.assertIsNone(events[0].pr_number) + self.assertEqual(events[0].correlation_id, "issue#637") + self.assertIsNone(events[1].issue_number) + self.assertEqual(events[1].pr_number, 813) + + def test_malformed_rows_skipped(self): + rows = [ + {"event_id": None, "event_type": "x", "kind": "issue", "number": 1}, + {"event_id": 5, "event_type": "", "kind": "issue", "number": 1}, + {"event_id": 6, "event_type": "ok", "message": "m", "created_at": None, "kind": "issue", "number": 1}, + ] + events = timeline.adapt_cp_events(rows) + self.assertEqual(len(events), 1) + self.assertIsNone(events[0].timestamp) + + def test_sensitive_event_flagged(self): + rows = [{"event_id": 1, "event_type": "lease.renew", "message": "m", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}] + events = timeline.adapt_cp_events(rows) + self.assertTrue(events[0].sensitive) + + +class TestCthAdapter(unittest.TestCase): + def test_cth_comment_becomes_event(self): + body = format_cth_body( + cth_type="Author Handoff", + status="ready", + next_owner="reviewer", + decision="implement timeline", + proof="commit abc1234 closes #637", + next_action="review PR", + ready_to_paste_prompt="Review PR #900 as reviewer", + ) + comments = [{"id": 42, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "jcwalker3"}}] + events = timeline.adapt_cth_comments(comments, kind="issue", number=637) + self.assertEqual(len(events), 1) + ev = events[0] + self.assertEqual(ev.source, timeline.SOURCE_GITEA_HANDOFF) + self.assertEqual(ev.event_type, "handoff:Author Handoff") + self.assertEqual(ev.actor, "jcwalker3") + self.assertEqual(ev.issue_number, 637) + self.assertEqual(ev.event_key, "cth:issue:637:42") + self.assertIn("#637", ev.evidence_refs) + self.assertIn("abc1234", ev.evidence_refs) + + def test_non_cth_comment_ignored(self): + comments = [{"id": 1, "body": "just a normal comment", "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}] + self.assertEqual(timeline.adapt_cth_comments(comments, kind="issue", number=1), []) + + +class TestRedaction(unittest.TestCase): + def test_cp_message_redacted(self): + rows = [{"event_id": 1, "event_type": "lease", "message": "token=ghs_ABCDEF1234567890abcdef here", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}] + events = timeline.adapt_cp_events(rows) + self.assertNotIn("ghs_ABCDEF1234567890abcdef", events[0].message or "") + + def test_handoff_decision_redacted(self): + body = format_cth_body( + cth_type="Blocker", + status="blocked", + next_owner="author", + decision="password=SuperSecret123! must rotate", + proof="none", + next_action="rotate", + ready_to_paste_prompt="Rotate the credential and retry", + ) + comments = [{"id": 7, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}] + events = timeline.adapt_cth_comments(comments, kind="issue", number=1) + self.assertNotIn("SuperSecret123!", events[0].decision or "") + + +class TestFilterSortPaginate(unittest.TestCase): + def _events(self): + return [ + timeline.WorkflowEvent(source="control_plane", event_type="a", event_key="cp:3", timestamp="2026-07-23T03:00:00Z", pr_number=813), + timeline.WorkflowEvent(source="control_plane", event_type="b", event_key="cp:1", timestamp="2026-07-23T01:00:00Z", issue_number=637), + timeline.WorkflowEvent(source="control_plane", event_type="c", event_key="cp:2", timestamp="2026-07-23T02:00:00Z", issue_number=637, session_id="sess-1"), + ] + + def test_filter_by_issue(self): + out = timeline.filter_events(self._events(), issue=637) + self.assertEqual({e.event_key for e in out}, {"cp:1", "cp:2"}) + + def test_filter_by_pr(self): + out = timeline.filter_events(self._events(), pr=813) + self.assertEqual([e.event_key for e in out], ["cp:3"]) + + def test_filter_by_session(self): + out = timeline.filter_events(self._events(), session="sess-1") + self.assertEqual([e.event_key for e in out], ["cp:2"]) + + def test_stable_sort_ascending(self): + out = timeline.sort_events(self._events()) + self.assertEqual([e.event_key for e in out], ["cp:1", "cp:2", "cp:3"]) + + def test_missing_timestamp_sorts_last(self): + evs = self._events() + [ + timeline.WorkflowEvent(source="control_plane", event_type="z", event_key="cp:9", timestamp=None) + ] + out = timeline.sort_events(evs) + self.assertEqual(out[-1].event_key, "cp:9") + + def test_pagination_windows_and_next_offset(self): + evs = timeline.sort_events(self._events()) + page1 = timeline.paginate(evs, limit=2, offset=0) + self.assertEqual(len(page1.events), 2) + self.assertEqual(page1.total, 3) + self.assertEqual(page1.next_offset, 2) + page2 = timeline.paginate(evs, limit=2, offset=2) + self.assertEqual(len(page2.events), 1) + self.assertIsNone(page2.next_offset) + + def test_pagination_bounds_coerced(self): + evs = self._events() + page = timeline.paginate(evs, limit=-5, offset=-3) + self.assertGreaterEqual(page.limit, 1) + self.assertEqual(page.offset, 0) + + +class TestCpReader(unittest.TestCase): + def test_reads_scoped_events_only(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + events, status = timeline.read_cp_events( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db + ) + self.assertTrue(status.ok) + # 3 scoped events; the dadeschools/Other event is excluded. + self.assertEqual(len(events), 3) + self.assertTrue(all(e.source == "control_plane" for e in events)) + # Redaction applied to the token-bearing message. + joined = " ".join(e.message or "" for e in events) + self.assertNotIn("ghs_ABCDEF1234567890abcdef", joined) + + def test_missing_db_degrades(self): + events, status = timeline.read_cp_events( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + db_path="/nonexistent/path/to/cp.sqlite3", + ) + self.assertEqual(events, []) + self.assertFalse(status.ok) + self.assertIsNotNone(status.reason) + + +class TestLoadTimeline(unittest.TestCase): + def test_handoff_not_run_without_thread_filter(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db + ) + d = snap.to_dict() + handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] + self.assertFalse(handoff["ok"]) + self.assertIn("thread-scoped", handoff["reason"]) + self.assertEqual(d["schema_version"], timeline.TIMELINE_SCHEMA_VERSION) + + def test_handoff_included_via_injected_source(self): + import tempfile + + body = format_cth_body( + cth_type="Author Handoff", status="ready", next_owner="reviewer", + decision="d", proof="#637", next_action="review", ready_to_paste_prompt="Review PR #1 now", + ) + + def source(kind, number): + return [{"id": 1, "body": body, "created_at": "2026-07-23T09:00:00Z", "user": {"login": "jcwalker3"}}] + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=source, + ) + d = snap.to_dict() + handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] + self.assertTrue(handoff["ok"]) + self.assertEqual(handoff["count"], 1) + # Both a CP event and the handoff event for issue 637 appear, sorted. + kinds = {e["source"] for e in d["events"]} + self.assertEqual(kinds, {"control_plane", "gitea_handoff"}) + + def test_failing_comment_source_degrades_only_handoff(self): + import tempfile + + def boom(kind, number): + raise RuntimeError("network down") + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=boom, + ) + d = snap.to_dict() + cp = [s for s in d["sources"] if s["name"] == "control_plane"][0] + handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] + self.assertTrue(cp["ok"]) + self.assertFalse(handoff["ok"]) + self.assertIn("network down", handoff["reason"]) + + +class TestTimelineApi(unittest.TestCase): + def setUp(self): + self._prev_db = os.environ.get(control_plane_db.DB_PATH_ENV) + self._prev_offline = os.environ.get("WEBUI_TEST_OFFLINE") + import tempfile + + self._tmpdir = tempfile.TemporaryDirectory() + self._db = os.path.join(self._tmpdir.name, "cp.sqlite3") + _seed_db(self._db) + os.environ[control_plane_db.DB_PATH_ENV] = self._db + os.environ["WEBUI_TEST_OFFLINE"] = "1" + self.client = TestClient(create_app()) + + def tearDown(self): + if self._prev_db is None: + os.environ.pop(control_plane_db.DB_PATH_ENV, None) + else: + os.environ[control_plane_db.DB_PATH_ENV] = self._prev_db + if self._prev_offline is None: + os.environ.pop("WEBUI_TEST_OFFLINE", None) + else: + os.environ["WEBUI_TEST_OFFLINE"] = self._prev_offline + self._tmpdir.cleanup() + + def test_api_returns_timeline(self): + resp = self.client.get("/api/v1/timeline") + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertEqual(body["schema_version"], timeline.TIMELINE_SCHEMA_VERSION) + self.assertIn("events", body) + self.assertIn("pagination", body) + self.assertGreaterEqual(body["pagination"]["total"], 1) + + def test_api_filter_by_issue(self): + resp = self.client.get("/api/v1/timeline?issue=637") + self.assertEqual(resp.status_code, 200) + events = resp.json()["events"] + self.assertTrue(events) + self.assertTrue(all(e["issue_number"] == 637 for e in events)) + + def test_api_pagination(self): + resp = self.client.get("/api/v1/timeline?limit=1&offset=0") + self.assertEqual(resp.status_code, 200) + pg = resp.json()["pagination"] + self.assertEqual(pg["limit"], 1) + self.assertEqual(len(resp.json()["events"]), 1) + if pg["total"] > 1: + self.assertTrue(pg["has_more"]) + + def test_api_is_read_only(self): + resp = self.client.post("/api/v1/timeline") + self.assertIn(resp.status_code, (404, 405)) + + def test_api_no_secret_leak(self): + resp = self.client.get("/api/v1/timeline?issue=637") + self.assertNotIn("ghs_ABCDEF1234567890abcdef", resp.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/app.py b/webui/app.py index cd8ab8b..26675bd 100644 --- a/webui/app.py +++ b/webui/app.py @@ -45,6 +45,7 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo from webui.worktree_views import render_worktrees_page from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict from webui.runtime_views import render_runtime_page +from webui.timeline import load_timeline, snapshot_to_dict as timeline_snapshot_to_dict _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) @@ -377,6 +378,101 @@ async def api_console_security_model(_request: Request) -> JSONResponse: }) +def _query_int(request: Request, key: str) -> int | None: + """Parse an optional integer query parameter; None when absent/invalid.""" + raw = request.query_params.get(key) + if raw is None or not str(raw).strip(): + return None + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return None + + +def _derive_remote(host: str) -> str: + """Map a Gitea host to its known short remote name (control-plane scope key).""" + text = (host or "").lower() + if "prgs" in text: + return "prgs" + if "dadeschools" in text: + return "dadeschools" + return text.split(".")[0] if text else "" + + +def _timeline_comment_source(host: str, org: str, repo: str): + """Build a fail-soft CTH-comment fetcher for one repo, or None when offline. + + Returns a callable ``(kind, number) -> list[comment]``. Credentials or + network failures raise inside the callable so ``load_timeline`` degrades the + handoff source rather than the whole timeline. Offline test mode yields no + live source so the handoff section reports ``not run``. + """ + import os + + from gitea_auth import api_fetch_page, get_auth_header, repo_api_url + + offline = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip().lower() in {"1", "true", "yes"} + if offline: + return None + auth = get_auth_header(host) + if not auth: + return None + + def _fetch(kind: str, number: int) -> list: + segment = "pulls" if kind == "pr" else "issues" + url = f"{repo_api_url(host, org, repo)}/{segment}/{int(number)}/comments" + comments: list = [] + page = 1 + while page <= 20: + raw, meta = api_fetch_page(url, auth, page=page, limit=50) + comments.extend(raw) + if bool(meta["is_final_page"]): + break + page += 1 + return comments + + return _fetch + + +async def api_v1_timeline(request: Request) -> JSONResponse: + """Read-only workflow-event timeline (#637). Filter by issue/PR/session.""" + from webui.queue_loader import _host_from_url # host normalisation helper + + registry, error = _load_project_registry() + if error is not None: + return JSONResponse(error.to_dict(), status_code=500) + project = registry.projects[0] if registry.projects else None + + org = request.query_params.get("org") or (project.gitea_owner if project else "") + repo = request.query_params.get("repo") or (project.repo_name if project else "") + host = _host_from_url(project.remote_host) if project else "" + remote = request.query_params.get("remote") or _derive_remote(host) + + if not (remote and org and repo): + return JSONResponse( + { + "error": "timeline_scope_unresolved", + "detail": "no project in registry and no remote/org/repo query params provided", + }, + status_code=400, + ) + + comment_source = _timeline_comment_source(host, org, repo) if (host and org and repo) else None + + snapshot = load_timeline( + remote=remote, + org=org, + repo=repo, + issue=_query_int(request, "issue"), + pr=_query_int(request, "pr"), + session=(request.query_params.get("session") or None), + limit=_query_int(request, "limit"), + offset=_query_int(request, "offset"), + comment_source=comment_source, + ) + return JSONResponse(timeline_snapshot_to_dict(snapshot)) + + async def method_not_allowed(request: Request, _exc: Exception) -> Response: path = request.url.path if path in _AUDIT_MUTATION_PATHS and request.method == "POST": @@ -415,6 +511,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette: Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), Route("/api/runtime", api_runtime, methods=["GET"]), + Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]), Route("/audit", audit, methods=["GET", "POST"]), Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/worktrees", worktrees, methods=["GET"]), diff --git a/webui/timeline.py b/webui/timeline.py new file mode 100644 index 0000000..e4597af --- /dev/null +++ b/webui/timeline.py @@ -0,0 +1,539 @@ +"""Workflow-event and conversation timeline model (#637, Phase 1). + +Operators cannot browse a unified timeline of workflow events, decisions, +tool calls, and handoffs: the evidence is scattered across control-plane +events, Gitea canonical handoff comments, and local logs. This module defines +one durable, versioned event schema and per-source adapters that normalise +those scattered records into a single ``WorkflowEvent`` stream, plus a +read-only query layer (filter by issue / PR / session, stable ordering, +pagination) that the ``/api/v1/timeline`` route serves. + +Design rules honoured here: + +- **Read-only.** Sources are read; nothing is mutated. The control-plane + database is opened through a ``mode=ro`` URI so a missing or unwritable DB + degrades to a reason instead of creating directories or running migrations. +- **Fail-soft per source.** An unavailable source degrades to a status with a + reason rather than raising, and a source that could not run is never + rendered as an empty-and-healthy timeline. +- **Redaction at the boundary, fail closed.** Every free-text field (event + messages, redacted tool arguments, decision/proof text) is run through the + console redaction policy before it leaves this module. An unredactable value + becomes the placeholder — an unredacted payload is never emitted, and a + generation error never drops raw data to a caller or a log. +- **Stable ordering.** Events sort by ``(timestamp, source_rank, event_key)`` + with a deterministic tiebreak, so pagination is stable across calls and + events with equal or missing timestamps keep a fixed order. + +Non-goals (from the issue): no full chat replay, no mutation of historical +events, no unredacted tool-argument storage. +""" + +from __future__ import annotations + +import re +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Iterable + +import control_plane_db +from webui import console_redaction + +# The schema is versioned so consumers can branch on shape. Bump on any +# breaking change to WorkflowEvent's serialized form. +TIMELINE_SCHEMA_VERSION = 1 + +# Known event sources and their deterministic ordering rank. When two events +# carry the same timestamp, the source rank breaks the tie before the +# per-source event key, so a control-plane event and a handoff comment minted +# in the same second always sort in a fixed order. +SOURCE_CONTROL_PLANE = "control_plane" +SOURCE_GITEA_HANDOFF = "gitea_handoff" +_SOURCE_RANK = { + SOURCE_CONTROL_PLANE: 0, + SOURCE_GITEA_HANDOFF: 1, +} + +# A timestamp far in the future so events with no parseable timestamp sort +# last (after everything real) instead of first, without raising. +_MISSING_TS_SORT = "9999-12-31T23:59:59Z" + + +def _parse_ts(value: str | None) -> str | None: + """Normalise a timestamp to ``...Z`` UTC, or None when unparseable.""" + if not value: + return None + text = str(value).strip() + if not text: + return None + candidate = text[:-1] + "+00:00" if text.endswith("Z") else text + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _redact(value: Any) -> Any: + """Redact a single free-text field, failing closed to the placeholder.""" + if value is None: + return None + return console_redaction.redact_text(str(value)) + + +@dataclass(frozen=True) +class WorkflowEvent: + """One normalised timeline event. + + Every field is optional except ``source``/``event_type``/``event_key`` + because sources carry different subsets. The class is frozen so an adapted + event is an immutable record; a consumer that needs a variant builds a new + one rather than mutating history. + """ + + source: str + event_type: str + event_key: str + timestamp: str | None = None + actor: str | None = None + role: str | None = None + issue_number: int | None = None + pr_number: int | None = None + session_id: str | None = None + tool_name: str | None = None + decision: str | None = None + message: str | None = None + correlation_id: str | None = None + evidence_refs: tuple[str, ...] = () + sensitive: bool = False + + def sort_key(self) -> tuple[str, int, str]: + return ( + self.timestamp or _MISSING_TS_SORT, + _SOURCE_RANK.get(self.source, 99), + self.event_key, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "source": self.source, + "event_type": self.event_type, + "event_key": self.event_key, + "timestamp": self.timestamp, + "actor": self.actor, + "role": self.role, + "issue_number": self.issue_number, + "pr_number": self.pr_number, + "session_id": self.session_id, + "tool_name": self.tool_name, + "decision": self.decision, + "message": self.message, + "correlation_id": self.correlation_id, + "evidence_refs": list(self.evidence_refs), + "sensitive": self.sensitive, + } + + +# --------------------------------------------------------------------------- # +# Adapters — pure functions from a source's raw records to WorkflowEvents. # +# Each is total: a malformed record is skipped, never raised on. # +# --------------------------------------------------------------------------- # + +# Event types whose payload is treated as sensitive and always redaction-hard +# (they can carry lease/session provenance or tool arguments). +_SENSITIVE_EVENT_HINTS = ("lease", "capability", "token", "auth", "secret") + +# Reference tokens (issue/PR/comment ids) and SHAs parsed out of proof text. +_EVIDENCE_REF_RE = re.compile(r"(?:#|PR\s*#?|issue\s*#?|comment\s*#?)(\d+)", re.IGNORECASE) +_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b") + + +def _kind_to_numbers(kind: str | None, number: int | None) -> tuple[int | None, int | None]: + """Map a control-plane work-item (kind, number) to (issue_no, pr_no).""" + if number is None: + return (None, None) + if kind == "pr": + return (None, int(number)) + if kind == "issue": + return (int(number), None) + return (None, None) + + +def _correlation_for(kind: str | None, number: int | None) -> str | None: + if number is None or kind not in ("issue", "pr"): + return None + return f"{kind}#{number}" + + +def _extract_evidence_refs(*texts: str | None) -> tuple[str, ...]: + refs: list[str] = [] + for text in texts: + if not text: + continue + for match in _EVIDENCE_REF_RE.finditer(text): + token = f"#{match.group(1)}" + if token not in refs: + refs.append(token) + for match in _SHA_RE.finditer(text): + token = match.group(0) + if token not in refs: + refs.append(token) + return tuple(refs) + + +def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]: + """Adapt control-plane ``events`` rows (joined to work_items) into events. + + Each row is expected to carry ``event_id``, ``event_type``, ``message``, + ``created_at`` and the joined work-item ``kind``/``number``. Rows missing + an id or type are skipped so a partially written table never raises. + """ + events: list[WorkflowEvent] = [] + for row in rows or []: + try: + event_id = row.get("event_id") + event_type = (row.get("event_type") or "").strip() + if event_id is None or not event_type: + continue + kind = row.get("kind") + number = row.get("number") + issue_no, pr_no = _kind_to_numbers(kind, number) + sensitive = any(hint in event_type.lower() for hint in _SENSITIVE_EVENT_HINTS) + events.append( + WorkflowEvent( + source=SOURCE_CONTROL_PLANE, + event_type=event_type, + event_key=f"cp:{event_id}", + timestamp=_parse_ts(row.get("created_at")), + issue_number=issue_no, + pr_number=pr_no, + session_id=(row.get("session_id") or None), + message=_redact(row.get("message")), + correlation_id=_correlation_for(kind, number), + sensitive=sensitive, + ) + ) + except Exception: + # A single malformed row must not sink the whole adaptation. + continue + return events + + +def adapt_cth_comments( + comments: Iterable[dict[str, Any]], + *, + kind: str, + number: int, +) -> list[WorkflowEvent]: + """Adapt Gitea Canonical Thread Handoff (CTH) comments into events. + + Only comments that parse as a CTH (``canonical_thread_handoff.parse_cth_comment``) + become events; ordinary comments are ignored. ``kind``/``number`` scope the + events to the issue or PR the comments belong to. + """ + # Imported lazily so this module has no import-time dependency on the + # handoff parser when only the control-plane adapter is used. + from canonical_thread_handoff import parse_cth_comment + + issue_no, pr_no = _kind_to_numbers(kind, number) + correlation = _correlation_for(kind, number) + events: list[WorkflowEvent] = [] + for comment in comments or []: + try: + body = comment.get("body") or "" + parsed = parse_cth_comment(body) + if not parsed: + continue + fields = parsed.get("fields") or {} + cth_type = parsed.get("cth_type") or "handoff" + comment_id = comment.get("id") + actor = (comment.get("user") or {}).get("login") + decision = fields.get("decision") + proof = fields.get("proof") + next_action = fields.get("next action") + events.append( + WorkflowEvent( + source=SOURCE_GITEA_HANDOFF, + event_type=f"handoff:{cth_type}", + event_key=f"cth:{kind}:{number}:{comment_id}", + timestamp=_parse_ts(comment.get("created_at")), + actor=actor, + role=_redact(fields.get("next owner")), + issue_number=issue_no, + pr_number=pr_no, + decision=_redact(decision), + message=_redact(next_action or fields.get("status")), + correlation_id=correlation, + evidence_refs=_extract_evidence_refs(proof, decision), + sensitive=False, + ) + ) + except Exception: + continue + return events + + +# --------------------------------------------------------------------------- # +# Read-only control-plane event source. # +# --------------------------------------------------------------------------- # + +_CP_EVENTS_QUERY = """ +SELECT e.event_id AS event_id, + e.event_type AS event_type, + e.message AS message, + e.created_at AS created_at, + w.kind AS kind, + w.number AS number +FROM events e +JOIN work_items w ON e.work_item_id = w.work_item_id +WHERE w.remote = ? AND w.org = ? AND w.repo = ? +""" + + +@dataclass(frozen=True) +class SourceStatus: + """Fail-soft status for one timeline source.""" + + name: str + ok: bool + reason: str | None = None + count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "ok": self.ok, "reason": self.reason, "count": self.count} + + +def read_cp_events( + *, + remote: str, + org: str, + repo: str, + db_path: str | None = None, +) -> tuple[list[WorkflowEvent], SourceStatus]: + """Read scoped control-plane events read-only. Never creates the DB. + + Opens the SQLite file through a ``mode=ro`` URI: a health/timeline read + must never create directories or run the schema migration that + ``ControlPlaneDB()`` performs on construction. A missing or unreadable DB + degrades to a status with a reason. + """ + path = (db_path or control_plane_db.default_db_path()).strip() + conn: sqlite3.Connection | None = None + try: + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + cursor = conn.execute(_CP_EVENTS_QUERY, (remote, org, repo)) + rows = [dict(r) for r in cursor.fetchall()] + except sqlite3.OperationalError as exc: + return ([], SourceStatus(SOURCE_CONTROL_PLANE, ok=False, reason=f"control-plane DB unavailable: {exc}")) + except sqlite3.Error as exc: + return ([], SourceStatus(SOURCE_CONTROL_PLANE, ok=False, reason=f"control-plane read failed: {exc}")) + finally: + if conn is not None: + conn.close() + events = adapt_cp_events(rows) + return (events, SourceStatus(SOURCE_CONTROL_PLANE, ok=True, count=len(events))) + + +# --------------------------------------------------------------------------- # +# Filter, sort, paginate. # +# --------------------------------------------------------------------------- # + + +def filter_events( + events: Iterable[WorkflowEvent], + *, + issue: int | None = None, + pr: int | None = None, + session: str | None = None, +) -> list[WorkflowEvent]: + """Filter events by issue number, PR number, and/or session id. + + Filters are conjunctive. A filter that names a dimension an event does not + carry excludes that event (an issue filter excludes PR-only events). + """ + out: list[WorkflowEvent] = [] + for ev in events: + if issue is not None and ev.issue_number != issue: + continue + if pr is not None and ev.pr_number != pr: + continue + if session is not None and ev.session_id != session: + continue + out.append(ev) + return out + + +def sort_events(events: Iterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Return events in stable timeline order (ascending).""" + return sorted(events, key=lambda ev: ev.sort_key()) + + +@dataclass(frozen=True) +class TimelinePage: + """One page of the sorted, filtered timeline.""" + + events: tuple[WorkflowEvent, ...] + total: int + limit: int + offset: int + + @property + def next_offset(self) -> int | None: + nxt = self.offset + len(self.events) + return nxt if nxt < self.total else None + + def to_dict(self) -> dict[str, Any]: + return { + "events": [ev.to_dict() for ev in self.events], + "pagination": { + "total": self.total, + "limit": self.limit, + "offset": self.offset, + "returned": len(self.events), + "next_offset": self.next_offset, + "has_more": self.next_offset is not None, + }, + } + + +_MAX_LIMIT = 500 +_DEFAULT_LIMIT = 50 + + +def _coerce_bounds(limit: int | None, offset: int | None) -> tuple[int, int]: + try: + lim = int(limit) if limit is not None else _DEFAULT_LIMIT + except (TypeError, ValueError): + lim = _DEFAULT_LIMIT + try: + off = int(offset) if offset is not None else 0 + except (TypeError, ValueError): + off = 0 + lim = max(1, min(lim, _MAX_LIMIT)) + off = max(0, off) + return (lim, off) + + +def paginate(events: list[WorkflowEvent], *, limit: int | None, offset: int | None) -> TimelinePage: + lim, off = _coerce_bounds(limit, offset) + window = events[off : off + lim] + return TimelinePage(events=tuple(window), total=len(events), limit=lim, offset=off) + + +# --------------------------------------------------------------------------- # +# Composition — load_timeline aggregates all sources, fail-soft. # +# --------------------------------------------------------------------------- # + +# A comment source is a callable that, given (kind, number), returns the raw +# Gitea comment list for that issue/PR. The route supplies a live fail-soft +# fetcher; tests supply a fixture. When None, the handoff source is reported as +# not-run (never silently empty-and-healthy). +CommentSource = Callable[[str, int], list[dict[str, Any]]] + + +@dataclass(frozen=True) +class TimelineSnapshot: + schema_version: int + remote: str + org: str + repo: str + filters: dict[str, Any] + page: TimelinePage + sources: tuple[SourceStatus, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "scope": {"remote": self.remote, "org": self.org, "repo": self.repo}, + "filters": self.filters, + "sources": [s.to_dict() for s in self.sources], + **self.page.to_dict(), + } + + +def load_timeline( + *, + remote: str, + org: str, + repo: str, + issue: int | None = None, + pr: int | None = None, + session: str | None = None, + limit: int | None = None, + offset: int | None = None, + db_path: str | None = None, + comment_source: CommentSource | None = None, +) -> TimelineSnapshot: + """Aggregate every timeline source into one filtered, paginated snapshot. + + Sources are read independently and fail soft: an unavailable source + contributes a ``SourceStatus`` with ``ok=False`` and a reason, and never + collapses the whole timeline. The handoff source only runs when a specific + issue or PR is requested (a handoff comment belongs to one thread) and a + ``comment_source`` is available; otherwise it is reported as ``not run`` + rather than as an empty-and-healthy source. + """ + all_events: list[WorkflowEvent] = [] + statuses: list[SourceStatus] = [] + + cp_events, cp_status = read_cp_events(remote=remote, org=org, repo=repo, db_path=db_path) + all_events.extend(cp_events) + statuses.append(cp_status) + + # Gitea handoff comments are thread-scoped: only fetch when the caller + # narrowed to one issue or PR, and only when a source was provided. + handoff_target: tuple[str, int] | None = None + if pr is not None: + handoff_target = ("pr", pr) + elif issue is not None: + handoff_target = ("issue", issue) + + if handoff_target is None: + statuses.append( + SourceStatus( + SOURCE_GITEA_HANDOFF, + ok=False, + reason="not run: handoff comments are thread-scoped; filter by issue or pr to include them", + ) + ) + elif comment_source is None: + statuses.append( + SourceStatus( + SOURCE_GITEA_HANDOFF, + ok=False, + reason="not run: no comment source configured for this timeline read", + ) + ) + else: + kind, number = handoff_target + try: + comments = comment_source(kind, number) or [] + handoff_events = adapt_cth_comments(comments, kind=kind, number=number) + all_events.extend(handoff_events) + statuses.append(SourceStatus(SOURCE_GITEA_HANDOFF, ok=True, count=len(handoff_events))) + except Exception as exc: # fail soft: a fetch/parse error degrades this source only + statuses.append( + SourceStatus(SOURCE_GITEA_HANDOFF, ok=False, reason=f"handoff source failed: {exc}") + ) + + filtered = filter_events(all_events, issue=issue, pr=pr, session=session) + ordered = sort_events(filtered) + page = paginate(ordered, limit=limit, offset=offset) + + return TimelineSnapshot( + schema_version=TIMELINE_SCHEMA_VERSION, + remote=remote, + org=org, + repo=repo, + filters={"issue": issue, "pr": pr, "session": session}, + page=page, + sources=tuple(statuses), + ) + + +def snapshot_to_dict(snapshot: TimelineSnapshot) -> dict[str, Any]: + return snapshot.to_dict() From ecda2001808f2c2a90f8c618ccff8728a2c694c7 Mon Sep 17 00:00:00 2001 From: Jason Walker Date: Thu, 23 Jul 2026 16:08:36 -0400 Subject: [PATCH 04/19] feat(webui): system-health dashboard (Closes #639) Phase 1 child of the Web Console epic #631. Adds the operator-facing system-health dashboard on top of the read-only system-health API landed by #634, so runtime problems are visible on a surface instead of being discovered late through failed LLM sessions. - webui/system_health_views.py (new): renders the SystemHealthSnapshot as readiness, stale-runtime parity, version/uptime, dependency, MCP namespace, probe-error, and recovery cards. - webui/app.py: GET /system-health, sharing load_system_health() with the JSON API so page and API cannot disagree. ?deep=1 behaves as on the API. - webui/layout.py: nav entry and health card/badge styles. - tests/test_webui_system_health_dashboard.py (new, 26 cases). - docs/webui-local-dev.md: route, field authority, and redaction split. Readiness honesty is preserved from the API: ready and readiness_complete render separately, a probe that did not run is listed under "Not probed" rather than counted healthy, and mutation safety is never claimed when the runtime is stale or parity is indeterminate. Redaction is split by field kind. Free text (probe details, reasons, probe errors) passes through system_health.redact. Structured fields (commit SHAs, probe names, statuses, timestamps) are HTML-escaped only: redact's opaque-token rule matches any run of 32 or more characters, so routing a 40-character git SHA through it rendered "[redacted]" and blanked the parity evidence the page exists to show. Non-goals honored: no restart or reload controls (Phase 2, #642), no manual process-kill guidance (#630). Read-only throughout. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 32 ++ tests/test_webui_system_health_dashboard.py | 341 ++++++++++++++++++++ webui/app.py | 20 ++ webui/layout.py | 20 ++ webui/system_health_views.py | 307 ++++++++++++++++++ 5 files changed, 720 insertions(+) create mode 100644 tests/test_webui_system_health_dashboard.py create mode 100644 webui/system_health_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 33dbf61..06ad035 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -54,6 +54,7 @@ status, onboarding checklist state, and the fail-closed error payloads (#635). | `/` | Home / operator overview | | `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) | | `/api/v1/system/health` | Structured read-only system health (#634) | +| `/system-health` | System-health dashboard — readiness, version/uptime, dependencies, MCP namespaces, stale-runtime parity (#639) | | `/queue` | Live PR and issue queue dashboard (#429) | | `/api/queue` | JSON queue export with pagination metadata | | `/projects` | Project registry list with status and onboarding progress (#427, #635) | @@ -233,6 +234,37 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## System-health dashboard (#639) + +`/system-health` renders the same snapshot the `/api/v1/system/health` API +returns, so the page and the API can never disagree. Cards: overall readiness, +stale-runtime parity, version and uptime, dependency probes, MCP namespaces, +probe errors (only when present), and recovery pointers. `?deep=1` opts into +the network probe exactly as the API does; the plain page load stays cheap. + +Field authority and honesty rules: + +* `ready` and `readiness_complete` are shown separately. A snapshot whose + required probes never ran is not the same as one that ran them and passed, + and the page never collapses the two into an unproven green. +* A probe that did not run appears under **Not probed**, never as healthy. +* `stale_runtime.mutation_safe` is displayed verbatim from the API. When the + runtime is stale, or when parity is indeterminate, the page warns and does + not claim mutation safety. +* MCP namespaces are reported `unproven`: the web process runs outside the + IDE-managed MCP client and cannot prove that path (#543). + +Redaction is split by field kind. Free text — probe details, readiness and +parity reasons, probe errors — passes through `system_health.redact`. +Structured fields — commit SHAs, probe names, statuses, timestamps — are +HTML-escaped only, because `redact`'s opaque-token rule matches any run of 32 +or more characters and would otherwise blank every 40-character git SHA, which +is precisely the evidence the parity view exists to show. + +The dashboard is read-only: no restart, reload, or process-kill control. Those +arrive in Phase 2 (#642). Recovery guidance points at the sanctioned client +reconnect / operator restart path — never a manual daemon kill (#630). + ## Deployment boundary (#435) MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** diff --git a/tests/test_webui_system_health_dashboard.py b/tests/test_webui_system_health_dashboard.py new file mode 100644 index 0000000..cd52cd8 --- /dev/null +++ b/tests/test_webui_system_health_dashboard.py @@ -0,0 +1,341 @@ +"""Tests for the system-health dashboard view (#639). + +Covers the acceptance criteria directly: the page renders the health DTO +fields (AC1), degraded dependencies are visible (AC2), stale runtime is warned +prominently and never rendered as mutation-safe (AC3), healthy and degraded +fixtures both render (AC4), and the shell carries a nav entry (AC5). +""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.deployment_boundary import scan_text_for_client_secrets +from webui.layout import NAV_ITEMS, render_page +from webui.system_health import ( + STATUS_DEGRADED, + STATUS_DOWN, + STATUS_OK, + STATUS_SKIPPED, + STATUS_UNPROVEN, + DependencyProbe, + StaleRuntime, + SystemHealthSnapshot, + VersionInfo, +) +from webui.system_health_views import render_system_health_page + +DASHBOARD_PATH = "/system-health" + + +def _version(*, known: bool = True) -> VersionInfo: + return VersionInfo( + git_sha="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd" if known else None, + git_describe="v0.4.1-12-g1c455b6" if known else None, + control_plane_schema_version=4 if known else None, + python_version="3.13.1", + known=known, + ) + + +def _parity(*, stale: bool = False, determinable: bool = True) -> StaleRuntime: + if stale: + return StaleRuntime( + daemon_head="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + checkout_head="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + remote_head="cccccccccccccccccccccccccccccccccccccccc", + stale=True, + determinable=True, + mutation_safe=False, + reasons=("runtime, checkout, and remote commits disagree",), + ) + if not determinable: + return StaleRuntime( + daemon_head=None, + checkout_head=None, + remote_head=None, + stale=False, + determinable=False, + mutation_safe=False, + reasons=("local checkout HEAD could not be read",), + ) + return StaleRuntime( + daemon_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + checkout_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + remote_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + stale=False, + determinable=True, + mutation_safe=True, + reasons=(), + ) + + +def _snapshot( + *, + status: str = STATUS_OK, + ready: bool = True, + readiness_complete: bool = True, + readiness_reasons: tuple[str, ...] = (), + dependencies: tuple[DependencyProbe, ...] | None = None, + parity: StaleRuntime | None = None, + namespaces: tuple[dict, ...] = (), + probe_errors: tuple[str, ...] = (), + version_known: bool = True, +) -> SystemHealthSnapshot: + if dependencies is None: + dependencies = ( + DependencyProbe( + name="control_plane_db", + kind="sqlite", + status=STATUS_OK, + detail="schema version 4", + required=True, + latency_ms=1.25, + metadata={"schema_version": 4}, + ), + ) + return SystemHealthSnapshot( + status=status, + ready=ready, + readiness_complete=readiness_complete, + readiness_reasons=readiness_reasons, + service="mcp-control-plane-webui", + mode="read-only", + version=_version(known=version_known), + started_at="2026-07-23T19:50:47+00:00", + uptime_seconds=3661.5, + timestamp="2026-07-23T20:51:48+00:00", + deep_probes_requested=False, + dependencies=dependencies, + mcp_namespaces=namespaces, + stale_runtime=parity if parity is not None else _parity(), + probe_errors=probe_errors, + ) + + +class TestHealthyRender(unittest.TestCase): + """AC1 / AC4 — every health DTO field reaches the page.""" + + def setUp(self): + self.html = render_system_health_page(_snapshot()) + + def test_readiness_fields_render(self): + self.assertIn("System health", self.html) + self.assertIn("Ready", self.html) + self.assertIn("mcp-control-plane-webui", self.html) + self.assertIn("read-only", self.html) + self.assertIn("2026-07-23T20:51:48+00:00", self.html) + + def test_version_and_uptime_render(self): + self.assertIn("1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", self.html) + self.assertIn("v0.4.1-12-g1c455b6", self.html) + self.assertIn("3.13.1", self.html) + self.assertIn("3661.500s", self.html) + self.assertIn("1.02h", self.html) + + def test_dependency_row_renders_with_latency(self): + self.assertIn("control_plane_db", self.html) + self.assertIn("sqlite", self.html) + self.assertIn("schema version 4", self.html) + self.assertIn("1.2 ms", self.html) + + def test_healthy_page_shows_no_stale_warning(self): + self.assertNotIn("Stale runtime:", self.html) + self.assertNotIn("Staleness", self.html) + + def test_unknown_version_is_labelled_not_faked(self): + html = render_system_health_page(_snapshot(version_known=False)) + self.assertIn("unknown", html) + self.assertIn("unresolved", html) + + +class TestDegradedRender(unittest.TestCase): + """AC2 — a degraded or unrun dependency is visible, not swallowed.""" + + def setUp(self): + self.deps = ( + DependencyProbe( + name="control_plane_db", + kind="sqlite", + status=STATUS_OK, + detail="schema version 4", + required=True, + latency_ms=0.9, + ), + DependencyProbe( + name="repository", + kind="git", + status=STATUS_DOWN, + detail="repository root is not a git checkout", + required=True, + latency_ms=4.0, + ), + DependencyProbe( + name="gitea", + kind="http", + status=STATUS_SKIPPED, + detail="deep probe not requested", + required=False, + ), + ) + self.html = render_system_health_page( + _snapshot( + status=STATUS_DEGRADED, + ready=False, + readiness_complete=False, + readiness_reasons=("required dependency 'repository' is down",), + dependencies=self.deps, + ) + ) + + def test_degraded_banner_names_the_dependency(self): + self.assertIn("Degraded dependencies:", self.html) + self.assertIn("repository", self.html) + + def test_not_run_probe_is_reported_separately(self): + self.assertIn("Not probed:", self.html) + self.assertIn("gitea", self.html) + self.assertIn("not counted", self.html) + + def test_not_ready_headline_and_reason(self): + self.assertIn("Not ready", self.html) + self.assertIn("required dependency 'repository' is down", self.html) + + def test_degraded_status_badge_present(self): + self.assertIn("badge-health-degraded", self.html) + self.assertIn("badge-health-down", self.html) + + def test_ready_but_incomplete_is_not_shown_as_plain_ready(self): + html = render_system_health_page( + _snapshot(ready=True, readiness_complete=False) + ) + self.assertIn("Ready (incomplete evidence)", html) + + +class TestStaleRuntimeWarning(unittest.TestCase): + """AC3 — staleness is prominent and never claims mutation safety.""" + + def test_stale_runtime_warns_and_denies_mutation_safety(self): + html = render_system_health_page(_snapshot(parity=_parity(stale=True))) + self.assertIn("Stale runtime:", html) + self.assertIn("do not treat this runtime as mutation-safe", html) + self.assertIn("Mutation safeFalse", html) + + def test_indeterminate_parity_is_not_reported_safe(self): + html = render_system_health_page( + _snapshot(parity=_parity(determinable=False)) + ) + self.assertIn("Staleness", html) + self.assertIn("Mutation safeFalse", html) + self.assertIn("DeterminableFalse", html) + + def test_healthy_parity_reports_mutation_safe_true(self): + html = render_system_health_page(_snapshot()) + self.assertIn("Mutation safeTrue", html) + + +class TestNamespacesAndErrors(unittest.TestCase): + def test_unproven_namespace_rows_render(self): + html = render_system_health_page( + _snapshot( + namespaces=( + { + "namespace": "gitea-author", + "required_tool": "gitea_lock_issue", + "status": STATUS_UNPROVEN, + "ide_namespace_proven": False, + "reason": "the web console cannot invoke the IDE-managed MCP client", + }, + ) + ) + ) + self.assertIn("gitea-author", html) + self.assertIn("gitea_lock_issue", html) + self.assertIn("badge-health-unproven", html) + + def test_no_namespaces_degrades_gracefully(self): + html = render_system_health_page(_snapshot(namespaces=())) + self.assertIn("No MCP namespaces are declared.", html) + + def test_probe_errors_render_when_present(self): + html = render_system_health_page( + _snapshot(probe_errors=("probe raised: disk offline",)) + ) + self.assertIn("Probe errors", html) + self.assertIn("disk offline", html) + + def test_probe_error_card_absent_when_clean(self): + self.assertNotIn("Probe errors", render_system_health_page(_snapshot())) + + +class TestReadOnlyAndRedaction(unittest.TestCase): + def test_no_restart_or_kill_controls(self): + html = render_system_health_page(_snapshot()) + self.assertNotIn("", html) + self.assertIn("<script>", html) + + +class TestNavAndRoute(unittest.TestCase): + """AC5 — the shell links the dashboard, and the route serves it.""" + + def setUp(self): + self.client = TestClient(create_app()) + + def test_nav_contains_system_health(self): + self.assertIn((DASHBOARD_PATH, "System health"), NAV_ITEMS) + + def test_rendered_shell_links_dashboard(self): + page = render_page(title="Home", body_html="

    x

    ") + self.assertIn(f'href="{DASHBOARD_PATH}"', page) + + def test_route_renders_dashboard(self): + response = self.client.get(DASHBOARD_PATH) + self.assertEqual(response.status_code, 200) + self.assertIn("System health", response.text) + self.assertIn("Stale-runtime parity", response.text) + + def test_route_is_read_only(self): + self.assertEqual(self.client.post(DASHBOARD_PATH).status_code, 405) + + def test_live_page_leaks_no_client_secret(self): + findings = scan_text_for_client_secrets(self.client.get(DASHBOARD_PATH).text) + self.assertEqual(findings, []) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/webui/app.py b/webui/app.py index 2a79d6c..9a2b801 100644 --- a/webui/app.py +++ b/webui/app.py @@ -51,6 +51,7 @@ from webui.system_health import ( process_uptime, snapshot_to_dict as system_health_to_dict, ) +from webui.system_health_views import render_system_health_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) @@ -121,6 +122,24 @@ async def api_system_health(request: Request) -> JSONResponse: return JSONResponse(payload, status_code=200 if snapshot.ready else 503) +async def system_health(request: Request) -> HTMLResponse: + """Read-only system-health dashboard (#639). + + Shares the #634 snapshot loader with the JSON API so the page can never + disagree with it. `?deep=1` opts into the network probe exactly as the API + does; the default page load stays cheap. The response is always 200: this + is an operator view that must render the degraded state, not withhold it. + """ + deep = _truthy_flag(request.query_params.get("deep")) + snapshot = load_system_health(deep=deep) + return HTMLResponse( + render_page( + title="System health", + body_html=render_system_health_page(snapshot), + ) + ) + + async def queue(_request: Request) -> HTMLResponse: snapshot = load_queue_snapshot() return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot))) @@ -433,6 +452,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette: Route("/", home, methods=["GET"]), Route("/health", health, methods=["GET"]), Route(SYSTEM_HEALTH_API_PATH, api_system_health, methods=["GET"]), + Route("/system-health", system_health, methods=["GET"]), Route("/queue", queue, methods=["GET"]), Route("/api/queue", api_queue, methods=["GET"]), Route("/projects", projects, methods=["GET"]), diff --git a/webui/layout.py b/webui/layout.py index 47bedc1..82e92ff 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -4,6 +4,7 @@ from __future__ import annotations NAV_ITEMS = ( ("/", "Home"), + ("/system-health", "System health"), ("/queue", "Queue"), ("/projects", "Projects"), ("/prompts", "Prompts"), @@ -161,6 +162,25 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: .badge-in-review {{ color: #9ec8f0; border-color: #3d5f7a; }} .badge-duplicate {{ color: #e0c27a; border-color: #6b5730; }} .badge-stale {{ color: #c9b8e8; border-color: #5a4a78; }} + .badge-health-ok {{ color: #8fd19e; border-color: #3d6b4a; }} + .badge-health-degraded {{ color: #e0c27a; border-color: #6b5730; }} + .badge-health-down {{ color: #f0a8a8; border-color: #7a3b3b; }} + .badge-health-skipped {{ color: var(--muted); }} + .badge-health-unproven {{ color: #c9b8e8; border-color: #5a4a78; }} + .health-card {{ + margin: 1.25rem 0; + padding: 0.85rem 1rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + }} + .health-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }} + .health-card h4 {{ margin: 1rem 0 0.35rem; font-size: 0.92rem; color: var(--muted); }} + .health-headline {{ color: var(--text); font-size: 1rem; margin: 0 0 0.5rem; }} + .health-degraded {{ border-left-color: #e0c27a; }} + .health-stale {{ border-left-color: #f0a8a8; }} + ul.reasons {{ margin: 0.35rem 0; padding-left: 1.15rem; color: var(--muted); font-size: 0.9rem; }} + ul.reasons li {{ margin-bottom: 0.3rem; }} {extra_head} diff --git a/webui/system_health_views.py b/webui/system_health_views.py new file mode 100644 index 0000000..e7ba492 --- /dev/null +++ b/webui/system_health_views.py @@ -0,0 +1,307 @@ +"""HTML views for the system-health dashboard (#639). + +Renders the read-only :class:`~webui.system_health.SystemHealthSnapshot` +produced by the Phase 1 system-health API (#634). The page offers no restart, +reload, or process-kill control: those are Phase 2 work, and manual process +kills are the contamination path #630 exists to prevent. + +Every free-text field passes through :func:`webui.system_health.redact` before +it reaches HTML, so a probe detail that captured a token or a credentialed URL +cannot leak through the dashboard even though the API redacts it already. +""" + +from __future__ import annotations + +import html + +from webui.system_health import ( + STATUS_DEGRADED, + STATUS_DOWN, + STATUS_OK, + STATUS_SKIPPED, + STATUS_UNPROVEN, + DependencyProbe, + SystemHealthSnapshot, + redact, +) + +_STATUS_BADGE_CLASS = { + STATUS_OK: "badge-health-ok", + STATUS_DEGRADED: "badge-health-degraded", + STATUS_DOWN: "badge-health-down", + STATUS_SKIPPED: "badge-health-skipped", + STATUS_UNPROVEN: "badge-health-unproven", +} + + +def _safe(value: object) -> str: + """Escape free text for HTML after redacting anything secret-shaped. + + Use this for every value that can carry arbitrary text — probe details, + reasons, probe errors — because those are where a credential could ride + along. + """ + return html.escape(redact(str(value))) + + +def _esc(value: object) -> str: + """Escape a structured field for HTML without redacting it. + + Commit SHAs, probe names, statuses, and timestamps are enumerated or + machine-generated, never credential-bearing. They must not go through + :func:`redact`: its opaque-token rule matches any 32-plus-character run, + so a 40-character git SHA would render as ``[redacted]`` and the parity + view — the one thing an operator reads this page for — would be blank. + """ + return html.escape(str(value)) + + +def _status_badge(status: str) -> str: + css = _STATUS_BADGE_CLASS.get(status, "badge-health-unproven") + return f'{_esc(status)}' + + +def _reason_list(reasons: tuple[str, ...], *, empty: str) -> str: + if not reasons: + return f"

    {html.escape(empty)}

    " + items = "".join(f"
  • {_safe(reason)}
  • " for reason in reasons) + return f"
      {items}
    " + + +def _readiness_card(snapshot: SystemHealthSnapshot) -> str: + """Overall readiness. + + ``ready`` and ``readiness_complete`` are shown separately on purpose: a + snapshot whose required probes never ran is not the same as one that ran + them and passed, and collapsing the two would render an unproven green. + """ + if snapshot.ready and snapshot.readiness_complete: + headline = "Ready" + elif snapshot.ready: + headline = "Ready (incomplete evidence)" + else: + headline = "Not ready" + + return ( + "
    " + f"

    Readiness {_status_badge(snapshot.status)}

    " + f"

    {html.escape(headline)}

    " + "" + f"" + f"" + f"" + "" + f"" + "" + f"" + f"" + "
    Service{_esc(snapshot.service)}
    Mode{_esc(snapshot.mode)}
    Ready{_esc(snapshot.ready)}
    Readiness evidence complete{_esc(snapshot.readiness_complete)}
    Deep probes requested{_esc(snapshot.deep_probes_requested)}
    Observed at{_esc(snapshot.timestamp)}
    " + "

    Readiness reasons

    " + f"{_reason_list(snapshot.readiness_reasons, empty='No readiness objections recorded.')}" + "
    " + ) + + +def _version_card(snapshot: SystemHealthSnapshot) -> str: + version = snapshot.version + uptime_hours = snapshot.uptime_seconds / 3600.0 + known = ( + "resolved" + if version.known + else "unresolved — version fields could not be read from the checkout" + ) + schema = version.control_plane_schema_version + return ( + "
    " + "

    Version and uptime

    " + "" + f"" + "" + f"" + "" + f"" + f"" + f"" + f"" + "" + f"" + "
    Git SHA{_esc(version.git_sha or 'unknown')}
    Git describe{_esc(version.git_describe or 'unknown')}
    Control-plane schema{_esc(schema if schema is not None else 'unknown')}
    Python{_esc(version.python_version)}
    Version status{html.escape(known)}
    Started at{_esc(snapshot.started_at)}
    Uptime{snapshot.uptime_seconds:.3f}s ({uptime_hours:.2f}h)
    " + "
    " + ) + + +def _dependency_rows(probes: tuple[DependencyProbe, ...]) -> str: + if not probes: + return "

    No dependency probes were reported.

    " + rows = [] + for probe in probes: + latency = ( + f"{probe.latency_ms:.1f} ms" if probe.latency_ms is not None else "n/a" + ) + rows.append( + "" + f"{_esc(probe.name)}" + f"{_esc(probe.kind)}" + f"{_status_badge(probe.status)}" + f"{_esc('required' if probe.required else 'optional')}" + f"{html.escape(latency)}" + f"{_safe(probe.detail)}" + "" + ) + return ( + "" + "" + "" + "" + f"{''.join(rows)}
    DependencyKindStatusRequirementLatencyDetail
    " + ) + + +def _dependency_card(snapshot: SystemHealthSnapshot) -> str: + degraded = [probe for probe in snapshot.dependencies if probe.ran and not probe.healthy] + not_run = [probe for probe in snapshot.dependencies if not probe.ran] + + banner = "" + if degraded: + names = ", ".join(sorted(probe.name for probe in degraded)) + banner += ( + "

    Degraded dependencies: " + f"{_esc(names)}

    " + ) + if not_run: + names = ", ".join(sorted(probe.name for probe in not_run)) + banner += ( + "

    Not probed: " + f"{_esc(names)} — these contribute no evidence and are not counted " + "as healthy.

    " + ) + + return ( + "
    " + "

    Dependencies

    " + f"{banner}" + f"{_dependency_rows(snapshot.dependencies)}" + "

    Details are redacted at the API boundary and again " + "before rendering; credentials are never displayed.

    " + "
    " + ) + + +def _namespace_card(snapshot: SystemHealthSnapshot) -> str: + if not snapshot.mcp_namespaces: + body = "

    No MCP namespaces are declared.

    " + else: + rows = [] + for entry in snapshot.mcp_namespaces: + rows.append( + "" + f"{_esc(entry.get('namespace'))}" + f"{_esc(entry.get('required_tool'))}" + f"{_status_badge(str(entry.get('status') or STATUS_UNPROVEN))}" + f"{_esc(entry.get('ide_namespace_proven'))}" + f"{_safe(entry.get('reason'))}" + "" + ) + body = ( + "" + "" + "" + "" + f"{''.join(rows)}
    NamespaceRequired toolStatusIDE-provenReason
    " + ) + return ( + "
    " + "

    MCP namespaces

    " + f"{body}" + "

    The web process runs outside the IDE-managed MCP " + "client, so namespace health is reported as unproven rather than " + "guessed (#543).

    " + "
    " + ) + + +def _stale_runtime_card(snapshot: SystemHealthSnapshot) -> str: + stale = snapshot.stale_runtime + if stale.stale: + warning = ( + "

    Stale runtime: " + "the running code, the checkout, and the remote-tracking commit " + "disagree. Capability gates may be evaluating obsolete code — " + "do not treat this runtime as mutation-safe.

    " + ) + elif not stale.determinable: + warning = ( + "

    Staleness " + "indeterminate: parity could not be proven, so this " + "runtime is not reported as mutation-safe.

    " + ) + else: + warning = "" + + return ( + "
    " + "

    Stale-runtime parity

    " + f"{warning}" + "" + "" + f"" + "" + f"" + "" + f"" + f"" + f"" + f"" + "
    Daemon head{_esc(stale.daemon_head or 'unknown')}
    Checkout head{_esc(stale.checkout_head or 'unknown')}
    Remote head{_esc(stale.remote_head or 'unknown')}
    Stale{_esc(stale.stale)}
    Determinable{_esc(stale.determinable)}
    Mutation safe{_esc(stale.mutation_safe)}
    " + f"{_reason_list(stale.reasons, empty='Runtime, checkout, and remote agree.')}" + "
    " + ) + + +def _probe_error_card(snapshot: SystemHealthSnapshot) -> str: + if not snapshot.probe_errors: + return "" + return ( + "
    " + "

    Probe errors

    " + f"{_reason_list(snapshot.probe_errors, empty='')}" + "
    " + ) + + +def _recovery_card() -> str: + """Sanctioned recovery pointers only — never a manual process kill (#630).""" + return ( + "
    " + "

    Recovery

    " + "

    This dashboard is read-only. Restart and reload " + "controls arrive in Phase 2 (#642); until then recovery runs through " + "the sanctioned client reconnect / operator restart path.

    " + "
      " + "
    • Runtime and session view — active profile, " + "workflow hashes, and shell health.
    • " + "
    • Reconnect the MCP client from the IDE, then re-run the blocked " + "cycle. Never kill the daemon process manually: unmanaged kills are " + "recorded as runtime contamination (#630).
    • " + "
    • See docs/webui-local-dev.md for the documented " + "recovery sequence.
    • " + "
    " + "
    " + ) + + +def render_system_health_page(snapshot: SystemHealthSnapshot) -> str: + """Render the full system-health dashboard body.""" + return ( + "

    System health

    " + "

    Read-only view of the Phase 1 system-health API " + "(/api/v1/system/health). Reload this page to refresh; " + "nothing here polls or mutates on your behalf.

    " + f"{_readiness_card(snapshot)}" + f"{_stale_runtime_card(snapshot)}" + f"{_version_card(snapshot)}" + f"{_dependency_card(snapshot)}" + f"{_namespace_card(snapshot)}" + f"{_probe_error_card(snapshot)}" + f"{_recovery_card()}" + ) From df58b5fb909c75bffc60b3b5237fce7176799da4 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 16:41:27 -0400 Subject: [PATCH 05/19] fix: remove safe worktrees before reassessing remote delete ownership (#851) Post-merge cleanup previously continued past ownership-blocked remote deletes, which skipped independently safe local worktree removal when the only block was worktree_binding. Remove the clean owned worktree first, reassess ownership, then delete the remote branch only if still safe. Preserve fail-closed protection for dirty/foreign ownership categories. Closes #851 Co-Authored-By: Claude Opus 4.8 (1M context) --- gitea_mcp_server.py | 234 +++++++++------- merged_cleanup_reconcile.py | 58 ++++ tests/test_branch_cleanup_guard.py | 372 +++++++++++++++++++++++++ tests/test_merged_cleanup_reconcile.py | 53 ++++ 4 files changed, 618 insertions(+), 99 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 87360bf..d059c25 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -11242,127 +11242,163 @@ def gitea_reconcile_merged_cleanups( if dry_run: report["dry_run"] = True report["executed"] = False + # #851: surface planned lifecycle order so dry-run matches execute. + report["planned_execution_orders"] = { + str(entry.get("pr_number")): entry.get("planned_execution_order") or [] + for entry in (report.get("entries") or []) + } return {"success": True, "performed": False, **report} verify_preflight_purity( remote, task="reconcile_merged_cleanups", org=org, repo=repo ) actions: list[dict] = [] + project_root = _canonical_local_git_root() + + def _ownership_records_for_branch( + head_branch: str, pr_num_int: int | None + ) -> list[dict]: + ownership_bundle = _collect_branch_ownership_records( + remote=remote, + host=h, + org=o, + repo=r, + branch=head_branch, + pr_number=pr_num_int, + project_root=project_root, + auth=auth, + base_api=base, + ) + ownership_records = list(ownership_bundle.get("records") or []) + if ownership_bundle.get("inventory_error"): + ownership_records.append( + { + "category": ( + branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR + ), + "status": "unknown", + "remote": remote, + "host": h, + "org": o, + "repo": r, + "branch": head_branch, + "reclaim_allowed": False, + "role": "inventory", + } + ) + return ownership_records + + def _attempt_owned_remote_delete( + *, + head_branch: str, + pr_num_int: int | None, + after_worktree_removal: bool = False, + ) -> dict: + """Fail-closed remote delete with live ownership reassessment (#851).""" + import urllib.parse + + ownership_records = _ownership_records_for_branch(head_branch, pr_num_int) + ownership = branch_cleanup_guard.assess_active_branch_ownership( + remote=remote, + org=o, + repo=r, + branch=head_branch, + host=h, + records=ownership_records, + ) + if ownership.get("block"): + return { + "action": "delete_remote_branch", + "branch": head_branch, + "success": False, + "performed": False, + "delete_acknowledged": False, + "verified_absent": False, + "blocker_kind": "active_branch_ownership", + "reasons": ownership.get("reasons") or [], + "blocking_categories": ownership.get("blocking_categories") or [], + "after_worktree_removal": after_worktree_removal, + "ownership_reassessed": after_worktree_removal, + } + + encoded = urllib.parse.quote(head_branch, safe="") + url = f"{base}/branches/{encoded}" + with _audited( + "delete_branch", + host=h, + remote=remote, + org=o, + repo=r, + target_branch=head_branch, + request_metadata={ + "branch": head_branch, + "source": "reconcile_merged_cleanups", + "ownership_checked": True, + "after_worktree_removal": after_worktree_removal, + }, + ): + api_request("DELETE", url, auth) + readback = _probe_remote_branch(h, o, r, auth, head_branch) + readback_assessment = branch_cleanup_guard.assess_post_delete_readback( + readback + ) + verified = bool(readback_assessment.get("verified_absent")) + return { + "action": "delete_remote_branch", + "branch": head_branch, + "success": bool(readback_assessment.get("ok")), + "performed": True, + "delete_acknowledged": True, + "verified_absent": verified, + "readback": readback_assessment.get("readback"), + "reasons": readback_assessment.get("reasons") or [], + "after_worktree_removal": after_worktree_removal, + "ownership_reassessed": after_worktree_removal, + } + for entry in report.get("entries") or []: head_branch = entry.get("head_branch") or "" remote_assessment = entry.get("remote_branch") or {} local_assessment = entry.get("local_worktree") or {} + pr_num = entry.get("pr_number") + try: + pr_num_int = int(pr_num) if pr_num is not None else None + except (TypeError, ValueError): + pr_num_int = None - if remote_assessment.get("safe_to_delete_remote"): - import urllib.parse - - pr_num = entry.get("pr_number") - try: - pr_num_int = int(pr_num) if pr_num is not None else None - except (TypeError, ValueError): - pr_num_int = None - ownership_bundle = _collect_branch_ownership_records( - remote=remote, - host=h, - org=o, - repo=r, - branch=head_branch, - pr_number=pr_num_int, - project_root=_canonical_local_git_root(), - auth=auth, - base_api=base, - ) - ownership_records = list(ownership_bundle.get("records") or []) - if ownership_bundle.get("inventory_error"): - ownership_records.append( - { - "category": ( - branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR - ), - "status": "unknown", - "remote": remote, - "host": h, - "org": o, - "repo": r, - "branch": head_branch, - "reclaim_allowed": False, - "role": "inventory", - } - ) - ownership = branch_cleanup_guard.assess_active_branch_ownership( - remote=remote, - org=o, - repo=r, - branch=head_branch, - host=h, - records=ownership_records, - ) - if ownership.get("block"): - actions.append( - { - "action": "delete_remote_branch", - "branch": head_branch, - "success": False, - "performed": False, - "delete_acknowledged": False, - "verified_absent": False, - "blocker_kind": "active_branch_ownership", - "reasons": ownership.get("reasons") or [], - "blocking_categories": ownership.get( - "blocking_categories" - ) - or [], - } - ) - continue - - encoded = urllib.parse.quote(head_branch, safe="") - url = f"{base}/branches/{encoded}" - with _audited( - "delete_branch", - host=h, - remote=remote, - org=o, - repo=r, - target_branch=head_branch, - request_metadata={ - "branch": head_branch, - "source": "reconcile_merged_cleanups", - "ownership_checked": True, - }, - ): - api_request("DELETE", url, auth) - readback = _probe_remote_branch(h, o, r, auth, head_branch) - readback_assessment = branch_cleanup_guard.assess_post_delete_readback( - readback - ) - verified = bool(readback_assessment.get("verified_absent")) - actions.append( - { - "action": "delete_remote_branch", - "branch": head_branch, - "success": bool(readback_assessment.get("ok")), - "performed": True, - "delete_acknowledged": True, - "verified_absent": verified, - "readback": readback_assessment.get("readback"), - "reasons": readback_assessment.get("reasons") or [], - } - ) - + # #851 lifecycle: when the target worktree is independently safe, remove + # it first so worktree_binding ownership does not permanently strand + # both the worktree and the remote branch. Never skip worktree removal + # merely because remote delete would be blocked by that binding. + # Ownership protection for remote delete remains fail-closed below. + worktree_removed = False if local_assessment.get("safe_to_remove_worktree"): result = merged_cleanup_reconcile.remove_local_worktree( - _canonical_local_git_root(), + project_root, head_branch, worktree_path=local_assessment.get("worktree_path"), ) actions.append({"action": "remove_local_worktree", **result}) + # Idempotent resume: absent worktree is already gone. + msg = (result.get("message") or "").lower() + worktree_removed = bool(result.get("success")) or ( + "not found" in msg + ) + + if remote_assessment.get("safe_to_delete_remote"): + actions.append( + _attempt_owned_remote_delete( + head_branch=head_branch, + pr_num_int=pr_num_int, + after_worktree_removal=worktree_removed, + ) + ) for scratch in report.get("reviewer_scratch_entries") or []: if not scratch.get("safe_to_remove_worktree"): continue result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree( - _canonical_local_git_root(), scratch.get("worktree_path") or "" + project_root, scratch.get("worktree_path") or "" ) actions.append({"action": "remove_reviewer_scratch_worktree", **result}) diff --git a/merged_cleanup_reconcile.py b/merged_cleanup_reconcile.py index 4a7299a..b436750 100644 --- a/merged_cleanup_reconcile.py +++ b/merged_cleanup_reconcile.py @@ -566,6 +566,10 @@ def build_pr_cleanup_entry( worktree_state=worktree_state, active_lock=active_lock, ) + planned = plan_cleanup_execution_order( + remote_assessment=remote, + local_assessment=local, + ) return { "pr_number": pr_number, "issue_number": issue_number, @@ -576,9 +580,63 @@ def build_pr_cleanup_entry( "merged": merged, "remote_branch": remote, "local_worktree": local, + # #851: dry-run and execute share the same lifecycle order description. + "planned_execution_order": planned, } +def plan_cleanup_execution_order( + *, + remote_assessment: dict[str, Any] | None, + local_assessment: dict[str, Any] | None, +) -> list[dict[str, Any]]: + """Describe independent worktree-then-reassess-then-remote cleanup order (#851). + + Remote ownership protection remains fail-closed at execute time. A worktree + that is independently safe to remove is never skipped merely because remote + deletion may be blocked by that same ``worktree_binding``. + """ + remote = remote_assessment or {} + local = local_assessment or {} + steps: list[dict[str, Any]] = [] + worktree_safe = bool(local.get("safe_to_remove_worktree")) + remote_safe = bool(remote.get("safe_to_delete_remote")) + + if worktree_safe: + steps.append( + { + "action": "remove_local_worktree", + "reason": "independently_safe_to_remove", + "phase": 1, + } + ) + if remote_safe: + if worktree_safe: + steps.append( + { + "action": "reassess_branch_ownership", + "reason": "after_worktree_removal_clear_worktree_binding", + "phase": 2, + } + ) + steps.append( + { + "action": "delete_remote_branch", + "reason": "only_if_independently_safe_after_reassessment", + "phase": 3, + } + ) + else: + steps.append( + { + "action": "delete_remote_branch", + "reason": "safe_to_delete_and_no_independent_worktree_removal", + "phase": 1, + } + ) + return steps + + def build_reconciliation_report( *, project_root: str, diff --git a/tests/test_branch_cleanup_guard.py b/tests/test_branch_cleanup_guard.py index 7a78998..0bd1fda 100644 --- a/tests/test_branch_cleanup_guard.py +++ b/tests/test_branch_cleanup_guard.py @@ -1266,6 +1266,378 @@ class TestSecondRemediationIntegration(unittest.TestCase): self.assertIn("delete_acknowledged", delete_actions[0]) self.assertTrue(delete_actions[0].get("verified_absent")) + def test_issue_851_worktree_removed_when_remote_blocked_only_by_worktree_binding(self): + """#851: remote blocked by worktree_binding must not skip safe worktree removal. + + Lifecycle: remove clean owned worktree → reassess ownership → delete + remote only if independently safe. Unrelated entries stay untouched. + """ + from mcp_server import gitea_reconcile_merged_cleanups + + target_branch = "fix/issue-844-exclude-epic-containers" + foreign_branch = "fix/issue-999-unrelated-active" + worktree_path = "/tmp/branches/fix-issue-844-exclude-epic-containers" + ownership_calls = [] + remove_calls = [] + delete_api_calls = [] + + def fake_collect(**kwargs): + ownership_calls.append(dict(kwargs)) + # Ownership is reassessed *after* independent worktree removal (#851). + # Target worktree is already gone → no worktree_binding remains. + # Foreign branch keeps an active author lease → remote delete blocked. + if kwargs.get("branch") == foreign_branch: + # Match session-bound org/repo + host used by the tool resolve path. + return { + "records": [ + { + "category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + "status": "active", + "remote": kwargs.get("remote") or "prgs", + "host": kwargs.get("host") or "gitea.example.com", + "org": kwargs.get("org") or "Scaled-Tech-Consulting", + "repo": kwargs.get("repo") or "Gitea-Tools", + "branch": foreign_branch, + "reclaim_allowed": False, + } + ], + "inventory_error": False, + } + return {"records": [], "inventory_error": False} + + def fake_remove(project_root, branch, worktree_path=None): + remove_calls.append( + {"branch": branch, "worktree_path": worktree_path} + ) + return { + "success": True, + "performed": True, + "message": f"removed worktree {worktree_path}", + "worktree_path": worktree_path, + } + + def fake_probe(h, o, r, auth, br): + return guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH + ) + + def fake_api(method, url, auth, **kwargs): + if method == "DELETE": + delete_api_calls.append(url) + return {} + + report = { + "entries": [ + { + "pr_number": 848, + "head_branch": target_branch, + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": { + "safe_to_remove_worktree": True, + "worktree_path": worktree_path, + }, + }, + { + "pr_number": 999, + "head_branch": foreign_branch, + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": { + "safe_to_remove_worktree": False, + "worktree_path": None, + }, + }, + ], + "reviewer_scratch_entries": [], + } + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.branch.delete", + "gitea.pr.close", + ], + "forbidden_operations": [], + }, + ).start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value=report, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees", + return_value=[], + ).start() + patch( + "mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed", + return_value=(True, []), + ).start() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch( + "mcp_server._collect_branch_ownership_records", + side_effect=fake_collect, + ).start() + patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start() + patch( + "mcp_server.merged_cleanup_reconcile.remove_local_worktree", + side_effect=fake_remove, + ).start() + self.mock_api.side_effect = fake_api + + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + remote="prgs", + ) + self.assertTrue(res.get("performed") or res.get("executed")) + actions = res.get("actions") or [] + + remove_actions = [ + a for a in actions if a.get("action") == "remove_local_worktree" + ] + self.assertEqual(len(remove_actions), 1, actions) + self.assertTrue(remove_actions[0].get("success")) + self.assertEqual(remove_calls[0]["branch"], target_branch) + self.assertEqual(remove_calls[0]["worktree_path"], worktree_path) + + # Target remote delete succeeds after worktree removal + reassessment. + target_deletes = [ + a + for a in actions + if a.get("action") == "delete_remote_branch" + and a.get("branch") == target_branch + ] + self.assertEqual(len(target_deletes), 1, actions) + self.assertTrue(target_deletes[0].get("success")) + self.assertTrue(target_deletes[0].get("after_worktree_removal")) + self.assertTrue(target_deletes[0].get("ownership_reassessed")) + self.assertTrue(target_deletes[0].get("verified_absent")) + + # Foreign branch remains protected (author lease) and is not deleted. + foreign_deletes = [ + a + for a in actions + if a.get("action") == "delete_remote_branch" + and a.get("branch") == foreign_branch + ] + self.assertEqual(len(foreign_deletes), 1, actions) + self.assertFalse(foreign_deletes[0].get("success")) + self.assertEqual( + foreign_deletes[0].get("blocker_kind"), "active_branch_ownership" + ) + self.assertIn( + guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + foreign_deletes[0].get("blocking_categories") or [], + ) + # Only the target branch should hit the DELETE API. + self.assertEqual(len(delete_api_calls), 1) + + # Ownership collected for target (post-removal) and foreign; worktree + # removal happened before target remote delete in the action log. + target_idx = next( + i + for i, a in enumerate(actions) + if a.get("action") == "remove_local_worktree" + ) + delete_idx = next( + i + for i, a in enumerate(actions) + if a.get("action") == "delete_remote_branch" + and a.get("branch") == target_branch + and a.get("success") + ) + self.assertLess(target_idx, delete_idx) + + def test_issue_851_dirty_worktree_not_removed_and_remote_stays_protected(self): + """#851: dirty/foreign worktrees remain protected; no unsafe cleanup.""" + from mcp_server import gitea_reconcile_merged_cleanups + + branch = "fix/issue-851-dirty" + remove_calls = [] + + def fake_collect(**kwargs): + return { + "records": [ + { + "category": guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING, + "status": "active", + "remote": kwargs.get("remote") or "prgs", + "host": kwargs.get("host") or "gitea.example.com", + "org": kwargs.get("org") or "Scaled-Tech-Consulting", + "repo": kwargs.get("repo") or "Gitea-Tools", + "branch": branch, + "reclaim_allowed": False, + } + ], + "inventory_error": False, + } + + report = { + "entries": [ + { + "pr_number": 851, + "head_branch": branch, + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": { + "safe_to_remove_worktree": False, + "worktree_path": "/tmp/dirty-wt", + }, + } + ], + "reviewer_scratch_entries": [], + } + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.branch.delete", + ], + "forbidden_operations": [], + }, + ).start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value=report, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees", + return_value=[], + ).start() + patch( + "mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed", + return_value=(True, []), + ).start() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch( + "mcp_server._collect_branch_ownership_records", + side_effect=fake_collect, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.remove_local_worktree", + side_effect=lambda *a, **k: remove_calls.append(k) or { + "success": True, + "performed": True, + }, + ).start() + self.mock_api.side_effect = lambda *a, **k: {} + + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + remote="prgs", + ) + actions = res.get("actions") or [] + self.assertEqual(remove_calls, []) + self.assertFalse( + any(a.get("action") == "remove_local_worktree" for a in actions) + ) + deletes = [ + a for a in actions if a.get("action") == "delete_remote_branch" + ] + self.assertEqual(len(deletes), 1) + self.assertFalse(deletes[0].get("success")) + self.assertEqual(deletes[0].get("blocker_kind"), "active_branch_ownership") + self.assertIn( + guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING, + deletes[0].get("blocking_categories") or [], + ) + + def test_issue_851_idempotent_resume_when_worktree_already_absent(self): + """#851: partial failures remain resumable and idempotent.""" + from mcp_server import gitea_reconcile_merged_cleanups + + branch = "fix/issue-851-resume" + ownership_calls = [] + + def fake_collect(**kwargs): + ownership_calls.append(kwargs) + return {"records": [], "inventory_error": False} + + def fake_remove(project_root, branch, worktree_path=None): + return { + "success": False, + "performed": False, + "message": f"worktree not found: {worktree_path}", + } + + def fake_probe(h, o, r, auth, br): + return guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH + ) + + report = { + "entries": [ + { + "pr_number": 851, + "head_branch": branch, + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": { + "safe_to_remove_worktree": True, + "worktree_path": "/tmp/already-gone", + }, + } + ], + "reviewer_scratch_entries": [], + } + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.branch.delete", + ], + "forbidden_operations": [], + }, + ).start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value=report, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees", + return_value=[], + ).start() + patch( + "mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed", + return_value=(True, []), + ).start() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch( + "mcp_server._collect_branch_ownership_records", + side_effect=fake_collect, + ).start() + patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start() + patch( + "mcp_server.merged_cleanup_reconcile.remove_local_worktree", + side_effect=fake_remove, + ).start() + self.mock_api.side_effect = lambda *a, **k: {} + + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + remote="prgs", + ) + actions = res.get("actions") or [] + removes = [a for a in actions if a.get("action") == "remove_local_worktree"] + deletes = [a for a in actions if a.get("action") == "delete_remote_branch"] + self.assertEqual(len(removes), 1) + self.assertFalse(removes[0].get("success")) + self.assertEqual(len(deletes), 1) + self.assertTrue(deletes[0].get("success")) + self.assertTrue(deletes[0].get("after_worktree_removal")) + self.assertTrue(ownership_calls) + if __name__ == "__main__": diff --git a/tests/test_merged_cleanup_reconcile.py b/tests/test_merged_cleanup_reconcile.py index 284ad37..e9a8709 100644 --- a/tests/test_merged_cleanup_reconcile.py +++ b/tests/test_merged_cleanup_reconcile.py @@ -12,6 +12,59 @@ import merged_cleanup_reconcile as mcr # noqa: E402 class TestMergedCleanupAssessment(unittest.TestCase): + def test_issue_851_plan_order_worktree_then_reassess_then_remote(self): + """#851 dry-run plan: remove worktree, reassess ownership, then remote.""" + plan = mcr.plan_cleanup_execution_order( + remote_assessment={"safe_to_delete_remote": True}, + local_assessment={"safe_to_remove_worktree": True}, + ) + actions = [s["action"] for s in plan] + self.assertEqual( + actions, + [ + "remove_local_worktree", + "reassess_branch_ownership", + "delete_remote_branch", + ], + ) + self.assertEqual(plan[0]["phase"], 1) + self.assertEqual(plan[-1]["phase"], 3) + self.assertIn("independently_safe", plan[0]["reason"]) + self.assertIn("reassessment", plan[-1]["reason"]) + + def test_issue_851_plan_remote_only_when_worktree_not_safe(self): + plan = mcr.plan_cleanup_execution_order( + remote_assessment={"safe_to_delete_remote": True}, + local_assessment={"safe_to_remove_worktree": False}, + ) + self.assertEqual([s["action"] for s in plan], ["delete_remote_branch"]) + self.assertNotIn("reassess_branch_ownership", [s["action"] for s in plan]) + + def test_issue_851_plan_worktree_only_when_remote_not_safe(self): + plan = mcr.plan_cleanup_execution_order( + remote_assessment={"safe_to_delete_remote": False}, + local_assessment={"safe_to_remove_worktree": True}, + ) + self.assertEqual([s["action"] for s in plan], ["remove_local_worktree"]) + + def test_issue_851_entry_includes_planned_execution_order(self): + entry = mcr.build_pr_cleanup_entry( + pr={ + "number": 848, + "title": "Closes #844", + "body": "", + "merged_at": "2026-07-23T00:00:00Z", + "head": {"ref": "fix/issue-844-x", "sha": "a" * 40}, + }, + project_root="/tmp/not-a-real-root", + open_pr_heads=set(), + remote_branch_exists=True, + head_on_master=True, + delete_capability_allowed=True, + ) + self.assertIn("planned_execution_order", entry) + self.assertIsInstance(entry["planned_execution_order"], list) + def test_extract_linked_issue_from_closes(self): issue = mcr.extract_linked_issue( "feat: cleanup (Closes #269)", From 8ba1c5b87ca28c08598c42142a15b1f09eda8983 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 16:45:43 -0400 Subject: [PATCH 06/19] fix(webui): make timeline session filter truthful and redact evidence refs (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediates the two blocking findings in review #522 on PR #849. F1 — the session filter dimension was dead end to end. No source could produce an event carrying a session identifier, so filter_events dropped every event whenever session was supplied and the API answered with a green, empty page. An empty result reads to an operator as "no such session activity", which is a stronger and false claim. Each source now declares which filter dimensions its records can actually carry. The control-plane events table is (event_id, work_item_id, event_type, message, created_at) and records no session, so that source declares the session dimension unsupported rather than pretending to answer it; the dead read of a non-existent session_id column is removed. A CTH handoff comment declares its own Session field, so the handoff adapter populates session_id from that declared field — authoritative source data, never inferred from an actor, work item, or message text. When no source that ran can carry a requested dimension, load_timeline refuses with ok=false and a structured error naming the unsupported filters and the per-source reason, and the route answers 422. A source that can answer the dimension and simply matched nothing still returns 200 with an honest empty page. Ordering, pagination, and the issue/PR filters are unchanged. F2 — evidence_refs bypassed redaction and could emit a credential verbatim. proof and decision were passed to _extract_evidence_refs before redaction, and the SHA pattern matched any 7-40 character lowercase hex run, which is exactly the shape of a Gitea access token. Redaction now runs first and every derived value is taken from the redacted text. A commit reference is recognised only where the source text declares one (commit, head, base, sha, ...), so an undeclared hex run is never lifted out of prose into a structured field; this also drops the ordinary-word noise the reviewer noted. Every reference is then independently revalidated against an allowed shape and a second redaction pass immediately before serialization, failing closed by dropping anything unproven and flagging the event sensitive. Actor is redacted for the same reason, and a secret-shaped session value is dropped rather than emitted. Legitimate issue, PR, short-SHA and full 40-character SHA references stay usable. Tests: 16 added. Session filtering is now driven through the CTH adapter and the composed load_timeline/API path rather than a hand-built WorkflowEvent, covering a match, an honest empty result, pagination and ordering under the filter, the 422 refusal, and the per-source support declaration. Redaction coverage asserts a synthetic 40-character hex value (not a real credential) appears nowhere in the complete serialized payload including evidence_refs, that the independent revalidation drops unproven references, and that legitimate references still resolve. pytest tests/test_webui_timeline.py: 42 passed (was 26). pytest -k webui: 340 passed, 270 subtests passed (was 324). Full suite: 4607 passed, 12 failed, 6 skipped, 684 subtests passed — the same 12 failures as the master baseline, none under webui/. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 38 ++++- tests/test_webui_timeline.py | 254 +++++++++++++++++++++++++++++ webui/app.py | 5 +- webui/timeline.py | 305 +++++++++++++++++++++++++++++++---- 4 files changed, 568 insertions(+), 34 deletions(-) diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index c06eb88..bde9379 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -348,14 +348,46 @@ and `offset` for pagination; `remote`, `org`, `repo` to override the default registry-project scope. Events sort ascending by `(timestamp, source_rank, event_key)`; missing timestamps sort last. +### Filter authority, and refusing what cannot be answered + +A filter dimension is only meaningful for a source whose records carry it. +Each source declares its own support in `_SOURCE_FILTER_SUPPORT` and reports it +per response as `supported_filters` / `unsupported_filters`: + +| Source | issue | pr | session | +|---|---|---|---| +| `control_plane` | yes | yes | **no** — the `events` table is `(event_id, work_item_id, event_type, message, created_at)` and records no session | +| `gitea_handoff` | yes | yes | yes — a CTH comment declares its own `Session:` field | + +`session_id` is read only from that declared CTH field. It is never inferred +from a work item, an actor, or message text, and a value that is +redaction-altering or bare-secret-shaped is dropped rather than emitted. + +When **no source that ran** can carry a requested dimension, the request is +refused rather than answered: the response is `422` with `ok:false` and a +structured `error` naming `unsupported_filters` and the per-source reason. A +`200` with zero events would tell an operator that no such activity exists, +which is a stronger — and false — claim than "this cannot be answered here". +A source that *can* answer the dimension and simply matched nothing still +returns `200` with `ok:true` and an empty page. + ### Redaction -Every free-text field (event messages, decision/proof text, roles) is passed -through the console redaction policy (`webui.console_redaction`, backed by -`gitea_audit.redact`) before it leaves the module, failing closed to the +Every free-text field (event messages, decision/proof text, roles, actors) is +passed through the console redaction policy (`webui.console_redaction`, backed +by `gitea_audit.redact`) before it leaves the module, failing closed to the placeholder. No unredacted tool arguments or secrets are ever emitted, and a generation error never drops raw data to a caller or a log. +Redaction also runs *before* any structured value is derived from free text. +`evidence_refs` are extracted from already-redacted proof/decision text, and a +commit reference is recognised only where the text declares one (`commit`, +`head`, `base`, `sha`, …). An undeclared 40-character hex run has the exact +shape of a Gitea access token, so it is never lifted out of prose into a +structured field. Every reference is then independently revalidated against an +allowed shape and a second redaction pass immediately before serialization; +anything unproven is dropped and the event is flagged `sensitive`. + ### Tests ```bash diff --git a/tests/test_webui_timeline.py b/tests/test_webui_timeline.py index 35384c0..2464e74 100644 --- a/tests/test_webui_timeline.py +++ b/tests/test_webui_timeline.py @@ -4,6 +4,7 @@ Covers the acceptance criteria: versioned schema, adaptation of control-plane events and Gitea handoff comments, filter by issue/PR/session, redaction of secret-like payloads, and stable pagination. """ +import json import os import sqlite3 import sys @@ -302,6 +303,243 @@ class TestLoadTimeline(unittest.TestCase): self.assertIn("network down", handoff["reason"]) +# A fabricated 40-character lowercase hex value with the shape of a Gitea +# personal access token. Never a real credential — its only job is to prove it +# cannot reach any part of a serialized timeline payload. +SYNTHETIC_SECRET_40_HEX = "a3f9c17be44d2058e6b17c9d0f5321ab77c4e9d1" + + +def _cth_comment(comment_id, *, created_at, session=None, decision="d", proof="none", **kw): + """Build one real CTH comment record, optionally declaring a session.""" + extra = {"Session": session} if session is not None else None + body = format_cth_body( + cth_type=kw.pop("cth_type", "Author Handoff"), + status=kw.pop("status", "ready"), + next_owner=kw.pop("next_owner", "reviewer"), + decision=decision, + proof=proof, + next_action=kw.pop("next_action", "review"), + ready_to_paste_prompt=kw.pop("ready_to_paste_prompt", "Review PR #1 now"), + extra_fields=extra, + ) + return { + "id": comment_id, + "body": body, + "created_at": created_at, + "user": {"login": kw.pop("login", "jcwalker3")}, + } + + +class TestSessionFilterThroughAdapter(unittest.TestCase): + """F1: the session dimension must be real, or explicitly refused. + + These drive the filter through the CTH adapter and the composed + ``load_timeline``/API path, never through a hand-built ``WorkflowEvent``. + """ + + def _source(self, comments): + return lambda kind, number: list(comments) + + def test_adapter_populates_declared_session(self): + events = timeline.adapt_cth_comments( + [_cth_comment(1, created_at="2026-07-23T05:00:00Z", session="sess-alpha")], + kind="issue", + number=637, + ) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].session_id, "sess-alpha") + + def test_session_filter_matches_through_adapter(self): + import tempfile + + comments = [ + _cth_comment(1, created_at="2026-07-23T09:00:00Z", session="sess-alpha"), + _cth_comment(2, created_at="2026-07-23T08:00:00Z", session="sess-alpha"), + _cth_comment(3, created_at="2026-07-23T10:00:00Z", session="sess-beta"), + ] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session="sess-alpha", db_path=db, + comment_source=self._source(comments), + ) + d = snap.to_dict() + self.assertTrue(d["ok"]) + self.assertIsNone(d["error"]) + keys = [e["event_key"] for e in d["events"]] + # Only the two sess-alpha events, still in ascending timestamp order. + self.assertEqual(keys, ["cth:issue:637:2", "cth:issue:637:1"]) + self.assertTrue(all(e["session_id"] == "sess-alpha" for e in d["events"])) + self.assertEqual(d["pagination"]["total"], 2) + + def test_session_filter_paginates_and_keeps_order(self): + import tempfile + + comments = [ + _cth_comment(i, created_at=f"2026-07-23T0{i}:00:00Z", session="sess-alpha") + for i in range(1, 4) + ] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + kwargs = dict( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session="sess-alpha", db_path=db, + comment_source=self._source(comments), + ) + page1 = timeline.load_timeline(limit=2, offset=0, **kwargs).to_dict() + page2 = timeline.load_timeline(limit=2, offset=2, **kwargs).to_dict() + self.assertEqual( + [e["event_key"] for e in page1["events"]], + ["cth:issue:637:1", "cth:issue:637:2"], + ) + self.assertEqual(page1["pagination"]["next_offset"], 2) + self.assertEqual([e["event_key"] for e in page2["events"]], ["cth:issue:637:3"]) + self.assertIsNone(page2["pagination"]["next_offset"]) + + def test_unknown_session_is_honestly_empty_when_supported(self): + """A source that *can* answer the dimension may legitimately match nothing.""" + import tempfile + + comments = [_cth_comment(1, created_at="2026-07-23T09:00:00Z", session="sess-alpha")] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + d = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session="sess-nope", db_path=db, + comment_source=self._source(comments), + ).to_dict() + self.assertTrue(d["ok"]) + self.assertEqual(d["events"], []) + + def test_session_filter_refused_when_no_source_can_answer(self): + """The F1 defect: an empty-and-healthy page for an unanswerable filter.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + d = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session="sess-alpha", db_path=db, comment_source=None, + ).to_dict() + self.assertFalse(d["ok"]) + self.assertEqual(d["error"]["code"], "filter_not_supported") + self.assertEqual(d["error"]["unsupported_filters"], ["session"]) + self.assertEqual(d["events"], []) + self.assertEqual(d["pagination"]["total"], 0) + # The refusal states which source could not answer, and why. + explained = {r["source"] for r in d["error"]["sources"]} + self.assertEqual(explained, {"control_plane", "gitea_handoff"}) + + def test_control_plane_declares_session_unsupported(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + d = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session="sess-alpha", db_path=db, + comment_source=self._source([]), + ).to_dict() + cp = [s for s in d["sources"] if s["name"] == "control_plane"][0] + handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] + self.assertNotIn("session", cp["supported_filters"]) + self.assertEqual(cp["unsupported_filters"], ["session"]) + self.assertIn("session", handoff["supported_filters"]) + self.assertEqual(handoff["unsupported_filters"], []) + + def test_secret_shaped_session_value_is_dropped(self): + events = timeline.adapt_cth_comments( + [_cth_comment(1, created_at="2026-07-23T05:00:00Z", session=SYNTHETIC_SECRET_40_HEX)], + kind="issue", + number=637, + ) + self.assertIsNone(events[0].session_id) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(events[0].to_dict())) + + +class TestEvidenceRefRedaction(unittest.TestCase): + """F2: evidence_refs must not be a hole in the redaction boundary.""" + + def _refs_for(self, *, proof="none", decision="d"): + events = timeline.adapt_cth_comments( + [_cth_comment(1, created_at="2026-07-23T05:00:00Z", proof=proof, decision=decision)], + kind="issue", + number=637, + ) + self.assertEqual(len(events), 1) + return events[0] + + def test_assigned_secret_never_reaches_evidence_refs(self): + ev = self._refs_for(proof=f"authenticated with token={SYNTHETIC_SECRET_40_HEX}") + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.evidence_refs) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + + def test_bare_secret_shaped_value_never_reaches_evidence_refs(self): + # An undeclared hex run in proof text is not evidence of anything, and + # proof itself is never serialized — so the value has no way out. + ev = self._refs_for(proof=f"proof {SYNTHETIC_SECRET_40_HEX}", decision="rotate the credential") + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.evidence_refs) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + + def test_secret_absent_from_complete_serialized_payload(self): + import tempfile + + comments = [ + _cth_comment( + 1, + created_at="2026-07-23T09:00:00Z", + proof=f"lease token={SYNTHETIC_SECRET_40_HEX} and bare {SYNTHETIC_SECRET_40_HEX}", + decision=f"rotate api_key={SYNTHETIC_SECRET_40_HEX}", + ) + ] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=lambda k, n: comments, + ) + payload = json.dumps(snap.to_dict()) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, payload) + # And the surface is clean by the redaction policy's own detectors. + from webui import console_redaction + + self.assertEqual(console_redaction.scan_for_secrets(snap.to_dict()), []) + + def test_legitimate_references_still_usable(self): + head = "4f3a464a1c455b6ecaaae9b6eef496c8f8ed451a" + ev = self._refs_for( + proof=f"closes #637, PR #849, commit abc1234, at head {head}", + decision="none", + ) + for token in ("#637", "#849", "abc1234", head): + self.assertIn(token, ev.evidence_refs) + + def test_undeclared_hex_words_are_not_references(self): + ev = self._refs_for(proof="the record was defaced and the facade decayed") + self.assertEqual(ev.evidence_refs, ()) + + def test_refs_revalidated_independently_before_serialization(self): + """Extraction is not trusted: the validator drops anything unproven.""" + safe, dropped = timeline._validated_evidence_refs( + ["#637", "abc1234", "not-a-ref", f"token={SYNTHETIC_SECRET_40_HEX}", ""] + ) + self.assertEqual(safe, ("#637", "abc1234")) + self.assertTrue(dropped) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(list(safe))) + + def test_clean_event_is_not_marked_sensitive(self): + ev = self._refs_for(proof="closes #637") + self.assertEqual(ev.evidence_refs, ("#637",)) + self.assertFalse(ev.sensitive) + + class TestTimelineApi(unittest.TestCase): def setUp(self): self._prev_db = os.environ.get(control_plane_db.DB_PATH_ENV) @@ -359,6 +597,22 @@ class TestTimelineApi(unittest.TestCase): resp = self.client.get("/api/v1/timeline?issue=637") self.assertNotIn("ghs_ABCDEF1234567890abcdef", resp.text) + def test_api_refuses_unanswerable_session_filter(self): + """No handoff source is configured offline, so nothing can carry a session.""" + resp = self.client.get("/api/v1/timeline?issue=637&session=sess-alpha") + self.assertEqual(resp.status_code, 422) + body = resp.json() + self.assertFalse(body["ok"]) + self.assertEqual(body["error"]["code"], "filter_not_supported") + self.assertEqual(body["error"]["unsupported_filters"], ["session"]) + self.assertEqual(body["events"], []) + self.assertEqual(body["pagination"]["total"], 0) + + def test_api_unfiltered_read_stays_ok(self): + body = self.client.get("/api/v1/timeline?issue=637").json() + self.assertTrue(body["ok"]) + self.assertIsNone(body["error"]) + if __name__ == "__main__": unittest.main() diff --git a/webui/app.py b/webui/app.py index 75e3f1b..c049707 100644 --- a/webui/app.py +++ b/webui/app.py @@ -503,7 +503,10 @@ async def api_v1_timeline(request: Request) -> JSONResponse: offset=_query_int(request, "offset"), comment_source=comment_source, ) - return JSONResponse(timeline_snapshot_to_dict(snapshot)) + # A filter no surviving source can carry is refused, not answered empty: + # a 200 with zero events would tell the operator no such activity exists. + status_code = 200 if snapshot.ok else 422 + return JSONResponse(timeline_snapshot_to_dict(snapshot), status_code=status_code) async def method_not_allowed(request: Request, _exc: Exception) -> Response: diff --git a/webui/timeline.py b/webui/timeline.py index e4597af..ded93c8 100644 --- a/webui/timeline.py +++ b/webui/timeline.py @@ -16,11 +16,19 @@ Design rules honoured here: - **Fail-soft per source.** An unavailable source degrades to a status with a reason rather than raising, and a source that could not run is never rendered as an empty-and-healthy timeline. +- **Answerable filters only.** Each source declares which filter dimensions it + can actually answer. A filter dimension no source that ran can carry is + refused with an explicit reason rather than silently matching nothing: an + empty page from an unanswerable filter reads to an operator as "no such + activity", which is a different — and false — statement. - **Redaction at the boundary, fail closed.** Every free-text field (event messages, redacted tool arguments, decision/proof text) is run through the - console redaction policy before it leaves this module. An unredactable value - becomes the placeholder — an unredacted payload is never emitted, and a - generation error never drops raw data to a caller or a log. + console redaction policy before it leaves this module, and *before* any + structured value is derived from it — evidence references are extracted from + redacted text, then independently revalidated before serialization. An + unredactable value becomes the placeholder, and a value that cannot be proven + safe is dropped — an unredacted payload is never emitted, and a generation + error never drops raw data to a caller or a log. - **Stable ordering.** Events sort by ``(timestamp, source_rank, event_key)`` with a deterministic tiebreak, so pagination is stable across calls and events with equal or missing timestamps keep a fixed order. @@ -33,7 +41,7 @@ from __future__ import annotations import re import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any, Callable, Iterable @@ -55,6 +63,32 @@ _SOURCE_RANK = { SOURCE_GITEA_HANDOFF: 1, } +# The filter dimensions the query layer accepts. +FILTER_ISSUE = "issue" +FILTER_PR = "pr" +FILTER_SESSION = "session" + +# Which dimensions each source can actually answer. This is a property of the +# underlying records, not of the query code: the control-plane ``events`` table +# is (event_id, work_item_id, event_type, message, created_at) and carries no +# session identity at all, so no control-plane event can ever match a session +# filter. A CTH handoff comment can declare its session as a field, so the +# handoff source answers all three. Filtering on a dimension the surviving +# sources cannot carry is refused in ``load_timeline`` rather than answered +# with an empty page. +_SOURCE_FILTER_SUPPORT: dict[str, tuple[str, ...]] = { + SOURCE_CONTROL_PLANE: (FILTER_ISSUE, FILTER_PR), + SOURCE_GITEA_HANDOFF: (FILTER_ISSUE, FILTER_PR, FILTER_SESSION), +} + +# Why a source cannot answer a dimension, for the refusal reason an operator reads. +_SOURCE_FILTER_LIMITS: dict[tuple[str, str], str] = { + (SOURCE_CONTROL_PLANE, FILTER_SESSION): ( + "control-plane events carry no session identity " + "(the events table has no session column)" + ), +} + # A timestamp far in the future so events with no parseable timestamp sort # last (after everything real) instead of first, without raising. _MISSING_TS_SORT = "9999-12-31T23:59:59Z" @@ -148,7 +182,26 @@ _SENSITIVE_EVENT_HINTS = ("lease", "capability", "token", "auth", "secret") # Reference tokens (issue/PR/comment ids) and SHAs parsed out of proof text. _EVIDENCE_REF_RE = re.compile(r"(?:#|PR\s*#?|issue\s*#?|comment\s*#?)(\d+)", re.IGNORECASE) -_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b") + +# A commit reference is only recognised when the text *declares* it as one. +# A bare lowercase hex run is not evidence of anything: at 40 characters it is +# exactly the shape of a Gitea personal access token, and at 7 it also matches +# ordinary words such as "defaced". Requiring an anchoring keyword keeps real +# references ("commit abc1234", "at head a209756...", "base caaae9b6") usable +# while refusing to lift an undeclared secret-shaped run out of free text. +_SHA_RE = re.compile( + r"(?i:\b(?:commit|sha|head|base|parent|revision|rev|merge[- ]base)\b[\s:=@#]*)" + r"([0-9a-f]{7,40})\b" +) + +# Shapes a serialized evidence reference is allowed to take. Anything else is +# dropped rather than emitted. +_REF_ISSUE_SHAPE = re.compile(r"^#[0-9]{1,9}$") +_REF_SHA_SHAPE = re.compile(r"^[0-9a-f]{7,40}$") + +# A long undelimited hex run with no declaring context is treated as credential +# material wherever it appears, never as an identifier. +_BARE_SECRET_SHAPE = re.compile(r"^[0-9a-f]{32,}$") def _kind_to_numbers(kind: str | None, number: int | None) -> tuple[int | None, int | None]: @@ -169,6 +222,14 @@ def _correlation_for(kind: str | None, number: int | None) -> str | None: def _extract_evidence_refs(*texts: str | None) -> tuple[str, ...]: + """Extract issue/PR and declared-commit references from **redacted** text. + + Callers must pass text that has already been through :func:`_redact`; this + function derives a structured field from its input, so extracting ahead of + redaction would republish whatever redaction was about to remove. Every + reference is revalidated by :func:`_validated_evidence_refs` before it is + serialized. + """ refs: list[str] = [] for text in texts: if not text: @@ -178,12 +239,64 @@ def _extract_evidence_refs(*texts: str | None) -> tuple[str, ...]: if token not in refs: refs.append(token) for match in _SHA_RE.finditer(text): - token = match.group(0) + token = match.group(1) if token not in refs: refs.append(token) return tuple(refs) +def _validated_evidence_refs(refs: Iterable[str]) -> tuple[tuple[str, ...], bool]: + """Independently revalidate references immediately before serialization. + + Extraction is not trusted on its own. A reference survives only when it has + a known reference shape and is unchanged by a second redaction pass — a + value the redaction policy would alter is credential material that must not + be emitted as a structured field. A full 40-character SHA stays usable + because extraction only accepts a hex run the source text explicitly + declared as a commit. Returns ``(safe_refs, dropped_any)``; ``dropped_any`` + marks the event sensitive so the drop is visible rather than silent. + """ + safe: list[str] = [] + dropped = False + for ref in refs or (): + try: + token = str(ref).strip() + if not token: + continue + recognised = bool(_REF_ISSUE_SHAPE.match(token) or _REF_SHA_SHAPE.match(token)) + if not recognised: + dropped = True + continue + if _redact(token) != token: + dropped = True + continue + if token not in safe: + safe.append(token) + except Exception: + # Fail closed: a reference that cannot be proven safe is dropped. + dropped = True + continue + return (tuple(safe), dropped) + + +def _safe_session_id(value: Any) -> str | None: + """Return a session identifier only when it is safe to emit. + + The value is authoritative source data — a session the record names for + itself — but it is still free text. It is dropped when redaction alters it + or when it is a bare secret-shaped hex run, so a credential parked in a + session field can never reach the payload or be echoed back by a filter. + """ + if value is None: + return None + text = str(value).strip() + if not text: + return None + if _BARE_SECRET_SHAPE.match(text): + return None + return text if _redact(text) == text else None + + def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]: """Adapt control-plane ``events`` rows (joined to work_items) into events. @@ -210,7 +323,13 @@ def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]: timestamp=_parse_ts(row.get("created_at")), issue_number=issue_no, pr_number=pr_no, - session_id=(row.get("session_id") or None), + # No session_id: the control-plane events table is + # (event_id, work_item_id, event_type, message, created_at) + # and records no session. Inventing one from the work item + # or the message text would be a guess, so this source + # declares the session dimension unsupported instead + # (_SOURCE_FILTER_SUPPORT) and the query layer refuses a + # session filter it cannot honestly answer. message=_redact(row.get("message")), correlation_id=_correlation_for(kind, number), sensitive=sensitive, @@ -250,25 +369,34 @@ def adapt_cth_comments( fields = parsed.get("fields") or {} cth_type = parsed.get("cth_type") or "handoff" comment_id = comment.get("id") - actor = (comment.get("user") or {}).get("login") - decision = fields.get("decision") - proof = fields.get("proof") - next_action = fields.get("next action") + # Redaction runs first, and every derived value is taken from the + # redacted text — deriving evidence refs from the raw proof would + # re-emit exactly what redaction was about to remove. + decision = _redact(fields.get("decision")) + proof = _redact(fields.get("proof")) + next_action = _redact(fields.get("next action")) + refs, refs_dropped = _validated_evidence_refs( + _extract_evidence_refs(proof, decision) + ) events.append( WorkflowEvent( source=SOURCE_GITEA_HANDOFF, event_type=f"handoff:{cth_type}", event_key=f"cth:{kind}:{number}:{comment_id}", timestamp=_parse_ts(comment.get("created_at")), - actor=actor, + actor=_redact((comment.get("user") or {}).get("login")), role=_redact(fields.get("next owner")), issue_number=issue_no, pr_number=pr_no, - decision=_redact(decision), - message=_redact(next_action or fields.get("status")), + # A CTH names its own session when the producer records one; + # it is read from that declared field, never inferred from + # unrelated text. + session_id=_safe_session_id(fields.get("session")), + decision=decision, + message=next_action or _redact(fields.get("status")), correlation_id=correlation, - evidence_refs=_extract_evidence_refs(proof, decision), - sensitive=False, + evidence_refs=refs, + sensitive=refs_dropped, ) ) except Exception: @@ -295,15 +423,50 @@ WHERE w.remote = ? AND w.org = ? AND w.repo = ? @dataclass(frozen=True) class SourceStatus: - """Fail-soft status for one timeline source.""" + """Fail-soft status for one timeline source. + + ``supported_filters`` states which filter dimensions this source's records + can carry; ``unsupported_filters`` names the requested dimensions it cannot, + so an operator can see *why* a source contributed nothing rather than being + left to read an empty list as an absence of activity. + """ name: str ok: bool reason: str | None = None count: int = 0 + supported_filters: tuple[str, ...] = () + unsupported_filters: tuple[str, ...] = () def to_dict(self) -> dict[str, Any]: - return {"name": self.name, "ok": self.ok, "reason": self.reason, "count": self.count} + return { + "name": self.name, + "ok": self.ok, + "reason": self.reason, + "count": self.count, + "supported_filters": list(self.supported_filters), + "unsupported_filters": list(self.unsupported_filters), + } + + +def _cp_status(*, ok: bool, reason: str | None = None, count: int = 0) -> SourceStatus: + return SourceStatus( + SOURCE_CONTROL_PLANE, + ok=ok, + reason=reason, + count=count, + supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_CONTROL_PLANE], + ) + + +def _handoff_status(*, ok: bool, reason: str | None = None, count: int = 0) -> SourceStatus: + return SourceStatus( + SOURCE_GITEA_HANDOFF, + ok=ok, + reason=reason, + count=count, + supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_GITEA_HANDOFF], + ) def read_cp_events( @@ -328,14 +491,14 @@ def read_cp_events( cursor = conn.execute(_CP_EVENTS_QUERY, (remote, org, repo)) rows = [dict(r) for r in cursor.fetchall()] except sqlite3.OperationalError as exc: - return ([], SourceStatus(SOURCE_CONTROL_PLANE, ok=False, reason=f"control-plane DB unavailable: {exc}")) + return ([], _cp_status(ok=False, reason=f"control-plane DB unavailable: {exc}")) except sqlite3.Error as exc: - return ([], SourceStatus(SOURCE_CONTROL_PLANE, ok=False, reason=f"control-plane read failed: {exc}")) + return ([], _cp_status(ok=False, reason=f"control-plane read failed: {exc}")) finally: if conn is not None: conn.close() events = adapt_cp_events(rows) - return (events, SourceStatus(SOURCE_CONTROL_PLANE, ok=True, count=len(events))) + return (events, _cp_status(ok=True, count=len(events))) # --------------------------------------------------------------------------- # @@ -437,6 +600,14 @@ CommentSource = Callable[[str, int], list[dict[str, Any]]] @dataclass(frozen=True) class TimelineSnapshot: + """One answered timeline query. + + ``ok`` is False when the query could not be answered as asked — currently + when a requested filter dimension no surviving source can carry was + supplied. The page is then empty *and* the snapshot says so, because an + ``ok`` empty page is a claim that no such activity exists. + """ + schema_version: int remote: str org: str @@ -444,9 +615,13 @@ class TimelineSnapshot: filters: dict[str, Any] page: TimelinePage sources: tuple[SourceStatus, ...] + ok: bool = True + error: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: return { + "ok": self.ok, + "error": self.error, "schema_version": self.schema_version, "scope": {"remote": self.remote, "org": self.org, "repo": self.repo}, "filters": self.filters, @@ -455,6 +630,28 @@ class TimelineSnapshot: } +def _unanswerable_reasons( + statuses: Iterable[SourceStatus], unanswerable: Iterable[str] +) -> list[dict[str, str]]: + """Explain, per source, why each unanswerable dimension went unanswered.""" + out: list[dict[str, str]] = [] + for status in statuses: + for dim in unanswerable: + if dim not in status.supported_filters: + reason = _SOURCE_FILTER_LIMITS.get( + (status.name, dim), f"this source's records carry no {dim} identity" + ) + elif not status.ok: + reason = ( + f"this source can carry {dim} but did not run: " + f"{status.reason or 'unavailable'}" + ) + else: + continue + out.append({"source": status.name, "filter": dim, "reason": reason}) + return out + + def load_timeline( *, remote: str, @@ -476,6 +673,11 @@ def load_timeline( issue or PR is requested (a handoff comment belongs to one thread) and a ``comment_source`` is available; otherwise it is reported as ``not run`` rather than as an empty-and-healthy source. + + A filter dimension that no surviving source can carry — a ``session`` + filter when the only source that ran is the control plane, whose events + record no session — is refused with ``ok=False`` and a structured error + instead of being answered with an empty page. """ all_events: list[WorkflowEvent] = [] statuses: list[SourceStatus] = [] @@ -494,16 +696,14 @@ def load_timeline( if handoff_target is None: statuses.append( - SourceStatus( - SOURCE_GITEA_HANDOFF, + _handoff_status( ok=False, reason="not run: handoff comments are thread-scoped; filter by issue or pr to include them", ) ) elif comment_source is None: statuses.append( - SourceStatus( - SOURCE_GITEA_HANDOFF, + _handoff_status( ok=False, reason="not run: no comment source configured for this timeline read", ) @@ -514,11 +714,56 @@ def load_timeline( comments = comment_source(kind, number) or [] handoff_events = adapt_cth_comments(comments, kind=kind, number=number) all_events.extend(handoff_events) - statuses.append(SourceStatus(SOURCE_GITEA_HANDOFF, ok=True, count=len(handoff_events))) + statuses.append(_handoff_status(ok=True, count=len(handoff_events))) except Exception as exc: # fail soft: a fetch/parse error degrades this source only - statuses.append( - SourceStatus(SOURCE_GITEA_HANDOFF, ok=False, reason=f"handoff source failed: {exc}") - ) + statuses.append(_handoff_status(ok=False, reason=f"handoff source failed: {exc}")) + + requested = tuple( + name + for name, value in ((FILTER_ISSUE, issue), (FILTER_PR, pr), (FILTER_SESSION, session)) + if value is not None + ) + statuses = [ + replace( + status, + unsupported_filters=tuple( + dim for dim in requested if dim not in status.supported_filters + ), + ) + for status in statuses + ] + filters = {"issue": issue, "pr": pr, "session": session} + + # A dimension is answerable only if a source that actually ran can carry it. + # If none can, refuse: an empty page would assert "no such activity", which + # is a claim this timeline is not in a position to make. + answerable: set[str] = set() + for status in statuses: + if status.ok: + answerable.update(status.supported_filters) + unanswerable = tuple(dim for dim in requested if dim not in answerable) + + if unanswerable: + return TimelineSnapshot( + schema_version=TIMELINE_SCHEMA_VERSION, + remote=remote, + org=org, + repo=repo, + filters=filters, + page=paginate([], limit=limit, offset=offset), + sources=tuple(statuses), + ok=False, + error={ + "code": "filter_not_supported", + "unsupported_filters": list(unanswerable), + "detail": ( + "no timeline source that ran can answer " + + ", ".join(f"'{dim}'" for dim in unanswerable) + + "; the result is refused rather than returned empty" + ), + "sources": _unanswerable_reasons(statuses, unanswerable), + }, + ) filtered = filter_events(all_events, issue=issue, pr=pr, session=session) ordered = sort_events(filtered) @@ -529,7 +774,7 @@ def load_timeline( remote=remote, org=org, repo=repo, - filters={"issue": issue, "pr": pr, "session": session}, + filters=filters, page=page, sources=tuple(statuses), ) From a1e5a4af8ce0fc5da4d30db2712c4407c4af43dd Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 18:45:54 -0400 Subject: [PATCH 07/19] fix(webui): validate externally influenced event_type at the timeline boundary (#637) Review #526 found that event_type reached the serialized timeline payload without crossing the redaction/validation boundary every other free-text field on the same event crosses. A synthetic secret-shaped 40-hex canary was redacted through message but survived verbatim through event_type on the same control-plane record. CTH heading path: CTH_TYPES is now the single authority for what a CTH type may be. canonical_thread_handoff.is_known_cth_type() is that authority, used by format_cth_body (write), assess_cth_comment (assess), and now the read path too; parse_cth_comment reports membership as cth_type_known and stays total. adapt_cth_comments serializes handoff: only for a declared type and otherwise emits the constant handoff:unrecognized, so arbitrary, malformed, secret-shaped, or whitespace-manipulated heading content never becomes an event_type. Control-plane path: a stored event_type is treated as source data. _safe_cp_event_type accepts only an ordinary identifier that is not a bare secret-shaped hex run and that a redaction pass leaves unchanged; anything else fails closed to the constant unsafe:redacted and marks the event sensitive. The value is never emitted verbatim, never partially sanitized, and never rewritten into a different valid-looking type. Remaining serialized-field audit: event_key ids must be plain numeric identifiers, the CTH adapter refuses a scope it cannot express, per-source failure reasons are redacted (they can quote an authenticated fetch error), and echoed scope/filter values are guarded so reflection is not a bypass. Legitimate values are preserved: every declared CTH type, the real producer types (assigned, lease_released, lease_adopted, dependency_edge_state_change, allocation, pr.opened, lease.renew), and the existing issue, PR, evidence, and full-SHA references. Tests seed the canary independently through both event_type paths with a benign message, so message redaction cannot be why they pass; each inspects event_type directly, asserts the canary is absent from the complete serialized payload, and asserts scan_for_secrets finds nothing. tests/test_webui_timeline.py 64 passed, 15 subtests pytest -k webui 362 passed, 285 subtests pytest -k redact 79 passed tests/test_canonical_thread_handoff.py tests/test_control_plane_db.py 29 passed Co-Authored-By: Claude Opus 4.8 (1M context) --- canonical_thread_handoff.py | 21 +- tests/test_webui_timeline.py | 405 ++++++++++++++++++++++++++++++++++- webui/timeline.py | 148 +++++++++++-- 3 files changed, 558 insertions(+), 16 deletions(-) diff --git a/canonical_thread_handoff.py b/canonical_thread_handoff.py index aa0a85b..6d262c2 100644 --- a/canonical_thread_handoff.py +++ b/canonical_thread_handoff.py @@ -46,6 +46,17 @@ _FIELD_RE = re.compile( ) +def is_known_cth_type(value: str | None) -> bool: + """True when *value* is a declared member of the :data:`CTH_TYPES` contract. + + ``CTH_TYPES`` is the single authority for what a CTH type may be. The + heading a comment carries is free text, so a *read* path that turns a parsed + type into something durable — a serialized field, a routing decision — must + check membership here rather than trust the parse or keep a list of its own. + """ + return (value or "").strip() in CTH_TYPES + + def format_cth_body( *, cth_type: str, @@ -60,7 +71,7 @@ def format_cth_body( ) -> str: """Render a canonical CTH comment body.""" normalized_type = (cth_type or "").strip() - if normalized_type not in CTH_TYPES: + if not is_known_cth_type(normalized_type): raise ValueError( f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" ) @@ -101,6 +112,12 @@ def parse_cth_comment(body: str) -> dict[str, Any] | None: fields[key] = match.group(2).strip() return { "cth_type": cth_type, + # The heading capture is unconstrained free text, so the parse states + # whether it satisfies the CTH_TYPES contract instead of leaving every + # reader to decide (or forget). Parsing stays total — an unknown type is + # still parsed and reported, never raised on — but a reader that turns + # the type into a durable value can now tell the two apart. + "cth_type_known": is_known_cth_type(cth_type), "fields": fields, "raw_body": text, } @@ -119,7 +136,7 @@ def assess_cth_comment(body: str) -> dict[str, Any]: } cth_type = parsed.get("cth_type") or "" - if cth_type not in CTH_TYPES: + if not is_known_cth_type(cth_type): reasons.append( f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" ) diff --git a/tests/test_webui_timeline.py b/tests/test_webui_timeline.py index 2464e74..39c7ea5 100644 --- a/tests/test_webui_timeline.py +++ b/tests/test_webui_timeline.py @@ -16,7 +16,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from starlette.testclient import TestClient import control_plane_db -from canonical_thread_handoff import format_cth_body +from canonical_thread_handoff import ( + CTH_TYPES, + MARKER, + format_cth_body, + is_known_cth_type, + parse_cth_comment, +) from webui import timeline from webui.app import create_app @@ -614,5 +620,402 @@ class TestTimelineApi(unittest.TestCase): self.assertIsNone(body["error"]) +def _seed_event(path: str, *, event_type: str, message: str, created_at: str) -> None: + """Append one control-plane event with a caller-chosen ``event_type``. + + The ``events`` table stores whatever a producer writes, so this seeds the + adapter the way a hostile or buggy producer would. + """ + conn = sqlite3.connect(path) + try: + wid = conn.execute( + "SELECT work_item_id FROM work_items WHERE kind='issue' AND number=637" + ).fetchone()[0] + conn.execute( + "INSERT INTO events(work_item_id, event_type, message, created_at) VALUES (?,?,?,?)", + (wid, event_type, message, created_at), + ) + conn.commit() + finally: + conn.close() + + +def _raw_cth_body(heading: str, **fields) -> str: + """Build a CTH comment with an arbitrary heading. + + ``format_cth_body`` refuses an undeclared type, which is exactly the write + path already covered. The read path must cope with a body that never went + through it, so this writes the marker and heading directly. + """ + lines = [MARKER, f"## CTH: {heading}", ""] + base = { + "Status": "ready", + "Next owner": "reviewer", + "Current blocker": "none", + "Decision": "d", + "Proof": "none", + "Next action": "review", + "Ready-to-paste prompt": "Review PR #1 now", + } + base.update(fields) + lines.extend(f"{key}: {value}" for key, value in base.items()) + return "\n".join(lines) + + +class TestEventTypeBoundary(unittest.TestCase): + """F2 residual: event_type must cross the same boundary as every other field. + + The canary is seeded through ``event_type`` *only*, with a benign message, + so message redaction cannot be what makes these pass. Each case inspects + ``event_type`` explicitly and then the complete serialized payload. + """ + + # ---- control-plane stored event_type ---------------------------------- # + + def test_cp_event_type_canary_never_serialized(self): + rows = [{ + "event_id": 1, + "event_type": SYNTHETIC_SECRET_40_HEX, + "message": "deploy completed", # benign: no redaction happens here + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + }] + events = timeline.adapt_cp_events(rows) + self.assertEqual(len(events), 1) + ev = events[0] + # The field itself, inspected directly. + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type) + self.assertEqual(ev.event_type, timeline.UNSAFE_EVENT_TYPE) + # Message redaction is provably not what saved us: it is untouched. + self.assertEqual(ev.message, "deploy completed") + # And nowhere in the serialized record. + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + # An unsafe value is a visible fact, not a silent substitution. + self.assertTrue(ev.sensitive) + + def test_cp_same_canary_in_message_and_event_type(self): + """The decisive case: one record, one value, two fields, one verdict.""" + rows = [{ + "event_id": 2, + "event_type": SYNTHETIC_SECRET_40_HEX, + "message": f"deploy token={SYNTHETIC_SECRET_40_HEX}", + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + }] + ev = timeline.adapt_cp_events(rows)[0] + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.message or "") + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + + def test_cp_unsafe_event_type_is_not_rewritten_as_a_valid_one(self): + """A refused value must not be disguised as some other real event type.""" + rows = [{ + "event_id": 3, + "event_type": SYNTHETIC_SECRET_40_HEX, + "message": "m", + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + }] + ev = timeline.adapt_cp_events(rows)[0] + for legitimate in ("allocation", "pr.opened", "lease.renew", "assigned"): + self.assertNotEqual(ev.event_type, legitimate) + + def test_cp_assigned_secret_in_event_type_refused(self): + rows = [{ + "event_id": 4, + "event_type": f"token={SYNTHETIC_SECRET_40_HEX}", + "message": "m", + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + }] + ev = timeline.adapt_cp_events(rows)[0] + self.assertEqual(ev.event_type, timeline.UNSAFE_EVENT_TYPE) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + + def test_cp_legitimate_event_types_preserved(self): + """Every real producer type in this repo must survive untouched.""" + legitimate = [ + "allocation", "pr.opened", "lease.renew", "assigned", + "lease_released", "lease_expired", "lease_abandoned", + "lease_adopted", "dependency_edge_state_change", + ] + rows = [ + { + "event_id": i, + "event_type": name, + "message": "m", + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + } + for i, name in enumerate(legitimate, start=1) + ] + events = timeline.adapt_cp_events(rows) + self.assertEqual([e.event_type for e in events], legitimate) + + def test_cp_malformed_event_type_shapes_refused(self): + for bad in ("has space", "1leading-digit", "x" * 200, "semi;colon", "new\nline"): + with self.subTest(event_type=bad): + rows = [{ + "event_id": 1, + "event_type": bad, + "message": "m", + "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", + "number": 637, + }] + events = timeline.adapt_cp_events(rows) + self.assertEqual(events[0].event_type, timeline.UNSAFE_EVENT_TYPE) + self.assertNotIn(bad, json.dumps(events[0].to_dict())) + + # ---- CTH heading event_type ------------------------------------------- # + + def test_cth_heading_canary_never_serialized(self): + comments = [{ + "id": 11, + "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "jcwalker3"}, + }] + events = timeline.adapt_cth_comments(comments, kind="issue", number=637) + self.assertEqual(len(events), 1) + ev = events[0] + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type) + self.assertEqual(ev.event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE) + # No message redaction is doing the work here — the message is benign. + self.assertEqual(ev.message, "review") + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + self.assertTrue(ev.sensitive) + + def test_cth_assigned_secret_heading_refused(self): + comments = [{ + "id": 12, + "body": _raw_cth_body(f"token={SYNTHETIC_SECRET_40_HEX}"), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "jcwalker3"}, + }] + ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0] + self.assertEqual(ev.event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict())) + + def test_cth_unknown_and_whitespace_headings_normalized(self): + for heading in ("Totally Made Up", "Author Handoff", "author handoff"): + with self.subTest(heading=heading): + comments = [{ + "id": 13, + "body": _raw_cth_body(heading), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "x"}, + }] + events = timeline.adapt_cth_comments(comments, kind="issue", number=637) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE) + + def test_cth_declared_types_all_preserved(self): + """Point 5: no legitimate declared type is lost to the new check.""" + for cth_type in sorted(CTH_TYPES): + with self.subTest(cth_type=cth_type): + comments = [{ + "id": 14, + "body": _raw_cth_body(cth_type), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "x"}, + }] + ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0] + self.assertEqual(ev.event_type, f"handoff:{cth_type}") + self.assertFalse(ev.sensitive) + + def test_cth_surrounding_whitespace_still_matches_contract(self): + comments = [{ + "id": 15, + "body": _raw_cth_body(" Author Handoff "), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "x"}, + }] + ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0] + self.assertEqual(ev.event_type, "handoff:Author Handoff") + + # ---- complete serialized payload, both paths -------------------------- # + + def _payload_clean(self, snapshot): + payload = json.dumps(snapshot.to_dict()) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, payload) + from webui import console_redaction + + self.assertEqual(console_redaction.scan_for_secrets(snapshot.to_dict()), []) + return snapshot.to_dict() + + def test_canary_absent_from_full_payload_via_cp_event_type(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + _seed_event( + db, + event_type=SYNTHETIC_SECRET_40_HEX, + message="routine deploy", + created_at="2026-07-23T06:00:00Z", + ) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=lambda k, n: [], + ) + d = self._payload_clean(snap) + types = [e["event_type"] for e in d["events"]] + self.assertIn(timeline.UNSAFE_EVENT_TYPE, types) + # The legitimate seeded types are still present and unchanged. + self.assertIn("allocation", types) + + def test_canary_absent_from_full_payload_via_cth_heading(self): + import tempfile + + comments = [{ + "id": 21, + "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX), + "created_at": "2026-07-23T09:00:00Z", + "user": {"login": "jcwalker3"}, + }] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=lambda k, n: comments, + ) + d = self._payload_clean(snap) + self.assertIn( + timeline.UNKNOWN_HANDOFF_EVENT_TYPE, + [e["event_type"] for e in d["events"]], + ) + + def test_canary_absent_when_seeded_through_both_paths_at_once(self): + import tempfile + + comments = [{ + "id": 22, + "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX), + "created_at": "2026-07-23T09:00:00Z", + "user": {"login": "jcwalker3"}, + }] + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + _seed_event( + db, + event_type=SYNTHETIC_SECRET_40_HEX, + message=f"deploy token={SYNTHETIC_SECRET_40_HEX}", + created_at="2026-07-23T06:00:00Z", + ) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=lambda k, n: comments, + ) + self._payload_clean(snap) + + +class TestSerializedFieldAudit(unittest.TestCase): + """The remaining externally influenced strings that reach the payload.""" + + def test_event_key_ids_must_be_plain_identifiers(self): + rows = [ + {"event_id": SYNTHETIC_SECRET_40_HEX, "event_type": "allocation", + "message": "m", "created_at": "2026-07-23T01:00:00Z", + "kind": "issue", "number": 637}, + {"event_id": 8, "event_type": "allocation", "message": "m", + "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 637}, + ] + events = timeline.adapt_cp_events(rows) + self.assertEqual([e.event_key for e in events], ["cp:8"]) + + def test_comment_id_must_be_a_plain_identifier(self): + comments = [{ + "id": SYNTHETIC_SECRET_40_HEX, + "body": _raw_cth_body("Author Handoff"), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "x"}, + }] + self.assertEqual(timeline.adapt_cth_comments(comments, kind="issue", number=637), []) + + def test_adapter_refuses_a_scope_it_cannot_express(self): + comments = [{ + "id": 1, + "body": _raw_cth_body("Author Handoff"), + "created_at": "2026-07-23T05:00:00Z", + "user": {"login": "x"}, + }] + self.assertEqual( + timeline.adapt_cth_comments(comments, kind="issue", number=SYNTHETIC_SECRET_40_HEX), + [], + ) + self.assertEqual( + timeline.adapt_cth_comments(comments, kind=SYNTHETIC_SECRET_40_HEX, number=1), + [], + ) + + def test_source_failure_reason_is_redacted(self): + def boom(kind, number): + raise RuntimeError(f"auth failed with token={SYNTHETIC_SECRET_40_HEX}") + + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + snap = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, db_path=db, comment_source=boom, + ) + d = snap.to_dict() + handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] + self.assertFalse(handoff["ok"]) + self.assertIn("handoff source failed", handoff["reason"]) + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(d)) + + def test_echoed_scope_and_filters_are_guarded(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "cp.sqlite3") + _seed_db(db) + d = timeline.load_timeline( + remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", + issue=637, session=SYNTHETIC_SECRET_40_HEX, db_path=db, + comment_source=lambda k, n: [], + ).to_dict() + self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(d)) + # Ordinary scope values are untouched, so the echo stays useful. + self.assertEqual( + d["scope"], + {"remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"}, + ) + self.assertEqual(d["filters"]["issue"], 637) + + +class TestCthTypeContract(unittest.TestCase): + """The contract has one authority; the read path consults it.""" + + def test_declared_types_are_known(self): + for cth_type in CTH_TYPES: + self.assertTrue(is_known_cth_type(cth_type)) + + def test_undeclared_types_are_not_known(self): + for value in ("", None, "Made Up", SYNTHETIC_SECRET_40_HEX, "author handoff"): + self.assertFalse(is_known_cth_type(value)) + + def test_parse_reports_contract_membership(self): + known = parse_cth_comment(_raw_cth_body("Author Handoff")) + self.assertTrue(known["cth_type_known"]) + self.assertEqual(known["cth_type"], "Author Handoff") + unknown = parse_cth_comment(_raw_cth_body("Not A Real Type")) + # Parsing stays total: the type is still reported, just not endorsed. + self.assertEqual(unknown["cth_type"], "Not A Real Type") + self.assertFalse(unknown["cth_type_known"]) + + if __name__ == "__main__": unittest.main() diff --git a/webui/timeline.py b/webui/timeline.py index ded93c8..1ec8284 100644 --- a/webui/timeline.py +++ b/webui/timeline.py @@ -203,6 +203,28 @@ _REF_SHA_SHAPE = re.compile(r"^[0-9a-f]{7,40}$") # material wherever it appears, never as an identifier. _BARE_SECRET_SHAPE = re.compile(r"^[0-9a-f]{32,}$") +# An event type reads like an identifier, but a stored one is externally +# influenced: any producer that writes the control-plane ``events`` table +# chooses the string. It reaches ``to_dict`` verbatim, so it is validated here +# rather than trusted because of where it came from. +_CP_EVENT_TYPE_SHAPE = re.compile(r"^[A-Za-z][A-Za-z0-9._:+-]{0,63}$") + +# Emitted in place of a value that cannot be proven safe. Deliberately not a +# plausible workflow type: an unsafe value is refused, never quietly rewritten +# into a different valid-looking one that would misdescribe the record. +UNSAFE_EVENT_TYPE = "unsafe:redacted" + +# Emitted for a CTH heading that is not a declared member of ``CTH_TYPES``. The +# contract is enforced on write (``format_cth_body``) and on assess; the read +# path the timeline uses enforces it too rather than assuming it was. +UNKNOWN_HANDOFF_EVENT_TYPE = "handoff:unrecognized" + +# A source record id is a plain integer in both sources it comes from: the +# control-plane ``events`` primary key and a Gitea comment id. ``event_key`` is +# serialized verbatim and is the pagination tiebreak, so anything else is +# refused rather than interpolated into it. +_RECORD_ID_SHAPE = re.compile(r"^[0-9]{1,19}$") + def _kind_to_numbers(kind: str | None, number: int | None) -> tuple[int | None, int | None]: """Map a control-plane work-item (kind, number) to (issue_no, pr_no).""" @@ -297,6 +319,64 @@ def _safe_session_id(value: Any) -> str | None: return text if _redact(text) == text else None +def _safe_record_id(value: Any) -> str | None: + """Return a source record id only when it is a plain numeric identifier. + + ``event_key`` is serialized verbatim and is the deterministic pagination + tiebreak, so an id is interpolated into it only when it has the shape both + real sources actually produce. A record whose identity cannot be trusted is + refused by the caller rather than keyed on. + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, int): + return str(value) + text = str(value).strip() + return text if _RECORD_ID_SHAPE.match(text) else None + + +def _safe_cp_event_type(value: Any) -> tuple[str, bool]: + """Validate a stored control-plane event type. Returns ``(type, unsafe)``. + + The stored value is externally influenced — whichever producer wrote the + ``events`` row chose the string — and ``to_dict`` serializes it verbatim, so + it passes a boundary of its own instead of relying on the one ``message`` + passes. A value survives only when it is an ordinary identifier, is not a + bare secret-shaped hex run, and is unchanged by a redaction pass. Anything + else fails closed to :data:`UNSAFE_EVENT_TYPE`: the record stays visible as + an audit entry, but the value itself is never republished — not verbatim, + not partially sanitized, and not rewritten into some other valid-looking + type that would misdescribe what happened. + """ + text = ("" if value is None else str(value)).strip() + if not text: + return ("", False) + if _BARE_SECRET_SHAPE.match(text): + return (UNSAFE_EVENT_TYPE, True) + if not _CP_EVENT_TYPE_SHAPE.match(text): + return (UNSAFE_EVENT_TYPE, True) + if _redact(text) != text: + return (UNSAFE_EVENT_TYPE, True) + return (text, False) + + +def _safe_echo(value: Any) -> Any: + """Guard a scalar that is echoed back rather than derived from a record. + + Query scope and filter values are caller-supplied and are reflected in the + response so an operator can see what was asked. Reflection is still + emission: a value redaction would alter, or a bare secret-shaped hex run, is + replaced by the placeholder instead of being echoed verbatim. Ordinary + scope and filter values pass through untouched. + """ + if value is None or isinstance(value, (int, bool)): + return value + text = str(value) + if _BARE_SECRET_SHAPE.match(text.strip()): + return console_redaction.REDACTED + return _redact(text) + + def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]: """Adapt control-plane ``events`` rows (joined to work_items) into events. @@ -307,14 +387,20 @@ def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]: events: list[WorkflowEvent] = [] for row in rows or []: try: - event_id = row.get("event_id") - event_type = (row.get("event_type") or "").strip() - if event_id is None or not event_type: + event_id = _safe_record_id(row.get("event_id")) + raw_event_type = (row.get("event_type") or "").strip() + if event_id is None or not raw_event_type: continue + # The stored type is source data, not a trusted constant: validate + # it before it is serialized, exactly as `message` below is redacted + # before it is serialized. + event_type, event_type_unsafe = _safe_cp_event_type(raw_event_type) kind = row.get("kind") number = row.get("number") issue_no, pr_no = _kind_to_numbers(kind, number) - sensitive = any(hint in event_type.lower() for hint in _SENSITIVE_EVENT_HINTS) + sensitive = event_type_unsafe or any( + hint in raw_event_type.lower() for hint in _SENSITIVE_EVENT_HINTS + ) events.append( WorkflowEvent( source=SOURCE_CONTROL_PLANE, @@ -355,7 +441,18 @@ def adapt_cth_comments( """ # Imported lazily so this module has no import-time dependency on the # handoff parser when only the control-plane adapter is used. - from canonical_thread_handoff import parse_cth_comment + from canonical_thread_handoff import is_known_cth_type, parse_cth_comment + + # ``kind``/``number`` are interpolated into event_key and correlation_id, so + # they are normalised once here. A scope this adapter cannot express is + # refused outright rather than serialized into an identifier. + kind = (kind or "").strip().lower() + if kind not in ("issue", "pr"): + return [] + try: + number = int(number) + except (TypeError, ValueError): + return [] issue_no, pr_no = _kind_to_numbers(kind, number) correlation = _correlation_for(kind, number) @@ -367,8 +464,17 @@ def adapt_cth_comments( if not parsed: continue fields = parsed.get("fields") or {} - cth_type = parsed.get("cth_type") or "handoff" - comment_id = comment.get("id") + cth_type = parsed.get("cth_type") or "" + comment_id = _safe_record_id(comment.get("id")) + if comment_id is None: + continue + # The CTH heading is free text: the parser accepts whatever follows + # "## CTH:", and only the write and assess paths check it against + # the contract. Check it here too — an unrecognised heading is + # reported as such rather than serialized into event_type, so + # arbitrary, malformed, or secret-shaped heading content has no way + # through. Declared types are preserved exactly. + cth_type_known = is_known_cth_type(cth_type) # Redaction runs first, and every derived value is taken from the # redacted text — deriving evidence refs from the raw proof would # re-emit exactly what redaction was about to remove. @@ -381,7 +487,11 @@ def adapt_cth_comments( events.append( WorkflowEvent( source=SOURCE_GITEA_HANDOFF, - event_type=f"handoff:{cth_type}", + event_type=( + f"handoff:{cth_type.strip()}" + if cth_type_known + else UNKNOWN_HANDOFF_EVENT_TYPE + ), event_key=f"cth:{kind}:{number}:{comment_id}", timestamp=_parse_ts(comment.get("created_at")), actor=_redact((comment.get("user") or {}).get("login")), @@ -396,7 +506,7 @@ def adapt_cth_comments( message=next_action or _redact(fields.get("status")), correlation_id=correlation, evidence_refs=refs, - sensitive=refs_dropped, + sensitive=refs_dropped or not cth_type_known, ) ) except Exception: @@ -453,7 +563,10 @@ def _cp_status(*, ok: bool, reason: str | None = None, count: int = 0) -> Source return SourceStatus( SOURCE_CONTROL_PLANE, ok=ok, - reason=reason, + # A failure reason is serialized like any other field and is often an + # exception string carrying a path or a transport error, so it crosses + # the redaction boundary too. Static reasons pass through unchanged. + reason=_redact(reason), count=count, supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_CONTROL_PLANE], ) @@ -463,7 +576,9 @@ def _handoff_status(*, ok: bool, reason: str | None = None, count: int = 0) -> S return SourceStatus( SOURCE_GITEA_HANDOFF, ok=ok, - reason=reason, + # Same boundary as the control-plane status: this reason can quote an + # error raised by a live authenticated fetch. + reason=_redact(reason), count=count, supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_GITEA_HANDOFF], ) @@ -623,8 +738,15 @@ class TimelineSnapshot: "ok": self.ok, "error": self.error, "schema_version": self.schema_version, - "scope": {"remote": self.remote, "org": self.org, "repo": self.repo}, - "filters": self.filters, + # Scope and filters are echoed caller input, not derived record + # data. Reflecting a value is still emitting it, so both cross the + # same boundary; ordinary scope and filter values are unchanged. + "scope": { + "remote": _safe_echo(self.remote), + "org": _safe_echo(self.org), + "repo": _safe_echo(self.repo), + }, + "filters": {key: _safe_echo(value) for key, value in self.filters.items()}, "sources": [s.to_dict() for s in self.sources], **self.page.to_dict(), } From 1301a57de4626118cc2a0d81ce6eb09da22ac3ac Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 19:16:28 -0400 Subject: [PATCH 08/19] docs(governance): MCP restart governance and authorization policy (#656) Adds docs/architecture/mcp-restart-governance.md, the restart-governance/v1 ADR defining who may restart the MCP control plane and under what conditions. - Recovery ladder (reconnect -> rebind -> scoped restart -> full restart -> host) with restart stated as the last resort. - Authorization matrix across author/reviewer/merger/reconciler/controller/ operator/admin; no LLM worker role may perform or authorize a full or host restart. - v1 authority decision recorded: controller approval + automated safety gates; quorum deferred to a superseding ADR. - Break-glass path with pre-declared incident and mandatory post-hoc audit. - Ambiguous policy state denies restart. - Stable policy IDs RG-01..RG-08 for later enforcement code to bind to. Cross-links the ADR from docs/safety-model.md and docs/webui-deployment.md, and adds tests/test_mcp_restart_governance_docs.py asserting acceptance criteria 1-5. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/mcp-restart-governance.md | 222 ++++++++++++++++++++ docs/safety-model.md | 14 ++ docs/webui-deployment.md | 9 + tests/test_mcp_restart_governance_docs.py | 107 ++++++++++ 4 files changed, 352 insertions(+) create mode 100644 docs/architecture/mcp-restart-governance.md create mode 100644 tests/test_mcp_restart_governance_docs.py diff --git a/docs/architecture/mcp-restart-governance.md b/docs/architecture/mcp-restart-governance.md new file mode 100644 index 0000000..da8fbd4 --- /dev/null +++ b/docs/architecture/mcp-restart-governance.md @@ -0,0 +1,222 @@ +# ADR: MCP restart governance and authorization policy + +- **Status:** Accepted (policy effective immediately for LLM and operator sessions; enforcement tooling may lag) +- **Date:** 2026-07-23 +- **Tracking issue:** [#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656) +- **Policy version:** `restart-governance/v1` +- **Related:** + - Umbrella: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — MCP restart / health hardening umbrella + - Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) — control-plane restart vision + - Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653) — restart hardening roadmap + - Coordinator: [#630](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/630) — forbids process-kill recovery + - Break-glass / console approval: [#642](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/642) + - Transport recovery: [#591](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/591), [#584](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/584) — EOF / transport-flap self-recovery + - Stable-control runtime split: `docs/architecture/mcp-stable-control-runtime-policy-adr.md` (#615) + - Client-namespace health: `docs/mcp-namespace-health.md` (#543) + - Reconnect-only EOF recovery: `docs/mcp-namespace-eof-recovery.md` + +## 1. Context + +The Gitea MCP server is the **control plane** for real issue and PR mutations +(create, comment, lock, review, merge, reconcile). The same process serves every +role namespace (`gitea-author`, `gitea-reviewer`, `gitea-merger`, +`gitea-reconciler`, `gitea-controller`) and holds the in-memory capability-gate +code loaded at startup. + +Restarting that process is destructive to concurrent work: + +- It resets every session's identity, preflight, and capability-lease binding. +- It can interrupt a mutation mid-critical-section (a lock acquire, a review + submit, a merge), leaving durable state half-written. +- Relaunching from the wrong checkout or worktree silently changes which code + the control plane runs, defeating master-parity gates (#420 / #615). + +Today there is **no durable written policy** stating who may restart MCP, under +what conditions, that restart is a last resort, and how controller approval, +automated safety gates, and break-glass interact. Operators and LLM sessions +therefore invent restart behavior ad hoc, which makes concurrent multi-role work +unsafe. #630 and #642 need this policy as their backbone. + +This ADR defines that policy. It does **not** implement coordinator code or HA +multi-instance restart (those are later children of #655). + +## 2. Decision + +### 2.1 v1 decision (recorded) + +**Restart authority in v1 is `controller approval + automated safety gates`.** + +A restart of the stable control runtime is authorized only when **both** hold: + +1. A **controller** role explicitly approves the restart, recording an audit + entry (who, why, scope, affected sessions), **and** +2. The **automated safety gates** pass: a completed drain acknowledgement (no + affected session is mid-critical-section) or a declared break-glass incident + (§2.5). + +Quorum among multiple controllers is **not** required day-one. It is deferred +unless a later investigation (tracked under #653) proves single-controller +approval is insufficient. This ADR records the v1 decision so enforcement code +(#630) has a fixed target; changing it requires a superseding ADR. + +### 2.2 Restart is a last resort — the recovery ladder + +Restart is the **last** rung. Before any restart, exhaust the narrower +recoveries, in order: + +1. **Reconnect** the IDE/client MCP namespace (transport EOF, `client is + closing: EOF`, transient `#584` flap). No process change. See + `docs/mcp-namespace-eof-recovery.md`. +2. **Refresh / rebind** the session workspace: re-run `gitea_whoami`, + `gitea_resolve_task_capability`, and pass an explicit validated + `worktree_path`. Fixes stale session context without touching the process. +3. **Scoped restart** of a single misbehaving namespace/service (where the + deployment supports per-service restart) rather than the whole control plane. +4. **Full restart** of the stable control runtime process — operator-owned, + controller-approved, drained. +5. **Host / infrastructure restart** — the broadest action; same authorization + as a full restart plus infrastructure ownership. + +A session **must** try rungs 1–2 and record why they were insufficient before +requesting a restart at rung 3 or above. Skipping straight to restart is a +policy violation. + +### 2.3 Authorization matrix + +| Role | Reconnect (1) | Refresh/rebind (2) | Scoped restart (3) | Full restart (4) | Host restart (5) | +|---|---|---|---|---|---| +| **author** | self | self | request only | **forbidden** | forbidden | +| **reviewer** | self | self | request only | **forbidden** | forbidden | +| **merger** | self | self | request only | **forbidden** | forbidden | +| **reconciler** | self | self | request only | **forbidden** | forbidden | +| **controller** | self | self | **approve** (+gates) | **approve** (+gates) | request to operator | +| **operator** | self | self | execute (controller-approved) | execute (controller-approved) | execute (controller-approved) | +| **admin** | self | self | execute | execute | execute (break-glass) | + +Legend: *self* = may perform for its own client session; *request only* = may +raise a restart request but not authorize or execute it; *approve* = may +authorize under §2.1 gates; *execute* = may perform the process action after the +authorization is recorded. + +Key invariants: + +- **No LLM worker role (author/reviewer/merger/reconciler) may perform or + authorize a full or host restart.** They may only reconnect/rebind their own + client and file a restart request. +- **Controller approval authorizes; operator/admin executes.** The approving + controller and the executing operator may be the same human, but both the + approval and the execution are audited. +- Privileged process actions (full restart, host restart) are reserved to + **operator/admin**, never to an automated worker. + +### 2.4 Approved conditions + +A restart at rung 3+ is approved only under one of these recorded conditions: + +- **No affected sessions:** the control plane has no live session that would be + interrupted (verified, not assumed). +- **Full drain acknowledged:** every affected session has drained + (no open critical section — no held mutation lease mid-write) and the drain is + acknowledged in the audit record. +- **Controller + gates:** controller approval plus passing automated safety + gates (§2.1), the standard v1 path. +- **Quorum:** not required in v1; reserved for a future superseding ADR. +- **Break-glass:** an incident-backed emergency exception (§2.5). + +Restart **never** bypasses mutation gates mid-critical-section. Drain before +restart is mandatory except under break-glass with a declared incident. + +### 2.5 Break-glass + +Break-glass is a **separate, narrower** authorization path for emergencies where +the normal drain-and-approve path cannot complete (e.g. the control plane is +wedged and cannot drain). + +Break-glass conditions: + +- A declared incident record exists (id, timestamp, declarer) **before** the + action. +- The action is taken by **operator or admin** authority only — never by an LLM + worker role, and never unilaterally by an operator with active peers when a + controller is reachable. +- The scope is the minimum necessary rung of the ladder. +- A **mandatory post-hoc audit** entry is filed: what was restarted, why the + normal path was impossible, which sessions were affected, and the incident id. + +Break-glass suspends the drain requirement, not the audit requirement. + +### 2.6 Explicit prohibitions + +- **A unilateral LLM or operator full restart while active peer sessions + exist is forbidden.** An LLM worker role must not kill, restart, or relaunch + the MCP process; a lone operator must not full-restart over live peer work + without controller approval or a break-glass incident. +- Process-kill recovery is forbidden as a routine tool (#630). This ADR does not + introduce a kill path. +- Ambiguous policy state **denies** restart (§4). + +## 3. Security requirements + +- Full restart and host restart are **privileged**; only operator/admin execute + them, only after a controller approval or break-glass incident is recorded. +- Break-glass is a distinct authorization path with its own audit mandate; it is + never the default and never silent. +- **Every approval and every restart action is audited** (who approved, who + executed, scope, affected sessions, condition, policy version). No restart is + authorized without a durable audit entry. + +## 4. Failure behavior + +**Ambiguous policy → deny restart.** If it cannot be established that a +restart is authorized under §2 — unknown affected-session state, missing +controller approval, absent break-glass incident, or an unclassifiable request — +the safe action is to **refuse** the restart and stop with a recovery report, +never to restart on assumption. + +## 5. Policy IDs (for enforcement code) + +Enforcement code (#630 coordinator, #642 console approval) binds to these stable +policy identifiers rather than to prose: + +| Policy ID | Statement | +|---|---| +| `RG-01` | Restart is last resort; rungs 1–2 must be tried and recorded first (§2.2). | +| `RG-02` | v1 authority = controller approval + automated safety gates (§2.1). | +| `RG-03` | No LLM worker role performs or authorizes full/host restart (§2.3). | +| `RG-04` | Full/host restart executed by operator/admin only, post approval (§2.3). | +| `RG-05` | Drain before restart is mandatory except break-glass with incident (§2.4). | +| `RG-06` | Break-glass requires a pre-declared incident and post-hoc audit (§2.5). | +| `RG-07` | Unilateral LLM/operator full restart with active peers is forbidden (§2.6). | +| `RG-08` | Ambiguous policy state denies restart (§4). | + +The `restart-governance/v1` **policy version** field is emitted on future +restart audit events so approvals can be reconciled against the policy revision +in force. + +## 6. Dogfooding + +Gitea-Tools governs its own MCP control plane by this policy. Author, reviewer, +merger, and reconciler sessions operating on this repository use the recovery +ladder (§2.2) — reconnect and rebind, never self-restart — and any real restart +of the Gitea-Tools stable control runtime follows the controller-approval + +drain path defined here. + +## 7. Acceptance and cross-links + +This ADR is the authoritative restart-governance policy. It **must** stay +cross-linked from the safety model and the web-console deployment boundary: + +- `docs/safety-model.md` § Process restart governance references this ADR. +- `docs/webui-deployment.md` references this ADR for restart/reload disposition. + +It is linked to its issue lineage — umbrella **#655**, vision **#652**, roadmap +**#653**, coordinator **#630**, and break-glass / console approval **#642** — in +§ Related above. + +## 8. Non-goals + +- Implementing the restart coordinator or approval state machine (#630, later + children of #655). +- Implementing HA multi-instance restart or quorum machinery. +- Introducing any process-kill or auto-restart tool; existing auto-restart + behavior must be inventoried before any new restart tool is enabled. diff --git a/docs/safety-model.md b/docs/safety-model.md index 31c740a..bbab241 100644 --- a/docs/safety-model.md +++ b/docs/safety-model.md @@ -46,3 +46,17 @@ If shell helpers are unavailable and MCP commit cannot run, stop with a recovery report (restart session, clear hung terminals, use MCP-native commit). See [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path (#260) and agent temp artifact cleanup (#261). + +## 7. Process restart governance + +Restarting the MCP control-plane process is destructive to concurrent multi-role +work and is governed by a dedicated policy. Restart is a **last resort** behind +narrower recoveries (reconnect, rebind), full/host restart is reserved to +operator/admin under **controller approval + automated safety gates**, a +unilateral LLM or operator full restart with active peers is **forbidden**, and +ambiguous policy state **denies** restart. Break-glass is a separate, +incident-backed path with a mandatory audit. + +See [`architecture/mcp-restart-governance.md`](architecture/mcp-restart-governance.md) +(#656) for the authorization matrix, the recovery ladder, break-glass +conditions, and the `RG-01`–`RG-08` policy IDs. diff --git a/docs/webui-deployment.md b/docs/webui-deployment.md index fa754ad..2ad46ab 100644 --- a/docs/webui-deployment.md +++ b/docs/webui-deployment.md @@ -55,6 +55,15 @@ shipped to the browser. assumption paths, and the client-secret policy. Use it to verify an instance is configured for internal-only operation. +## Process restart / reload disposition + +The console never exposes a restart or reload control; process restart of the +MCP control-plane runtime is governed separately. Restart is a last resort behind +reconnect/rebind, full restart is operator/admin-only under controller approval +plus safety gates, and break-glass is an incident-backed path. See +[`architecture/mcp-restart-governance.md`](architecture/mcp-restart-governance.md) +(#656). + ## Non-goals (MVP) - Full SSO or session login in the UI diff --git a/tests/test_mcp_restart_governance_docs.py b/tests/test_mcp_restart_governance_docs.py new file mode 100644 index 0000000..5b12e50 --- /dev/null +++ b/tests/test_mcp_restart_governance_docs.py @@ -0,0 +1,107 @@ +"""Documentation acceptance for the MCP restart governance ADR (#656). + +Enforces issue #656 acceptance criteria: + +* AC1 — policy document exists with an authorization matrix and the recorded + v1 decision (controller approval + automated safety gates). +* AC2 — restart is stated as a last resort with enumerated narrower recoveries. +* AC3 — a unilateral LLM full restart with affected sessions is forbidden. +* AC4 — break-glass conditions are listed. +* AC5 — the ADR is linked to #655, #652, #653, #630, #642, and is cross-linked + from the safety model and the web-console deployment boundary docs. +""" +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +ADR = REPO_ROOT / "docs" / "architecture" / "mcp-restart-governance.md" +ADR_BASENAME = "mcp-restart-governance.md" + +CROSS_LINK_DOCS = ( + REPO_ROOT / "docs" / "safety-model.md", + REPO_ROOT / "docs" / "webui-deployment.md", +) + +LINKED_ISSUES = ("#655", "#652", "#653", "#630", "#642") +POLICY_IDS = ("RG-01", "RG-02", "RG-03", "RG-04", "RG-05", "RG-06", "RG-07", "RG-08") + + +def _read(path: Path) -> str: + assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}" + return path.read_text(encoding="utf-8") + + +def test_ac1_adr_exists_with_matrix_and_v1_decision(): + text = _read(ADR) + lower = text.lower() + assert text.lstrip().startswith("#"), "ADR lacks a title" + assert "#656" in text + assert "authorization matrix" in lower + # The matrix is a real table with the worker and privileged roles. + for role in ("author", "reviewer", "merger", "reconciler", "controller", + "operator", "admin"): + assert role in lower, f"authorization matrix missing role {role!r}" + # Recorded v1 decision. + assert "restart-governance/v1" in text + assert "controller approval" in lower and "automated safety gates" in lower + + +def test_ac2_restart_is_last_resort_with_narrower_recoveries(): + text = _read(ADR) + lower = text.lower() + assert "last resort" in lower + # Enumerated narrower recoveries precede full restart on the ladder. + for rung in ("reconnect", "rebind", "scoped restart", "full restart", + "host"): + assert rung in lower, f"recovery ladder missing rung {rung!r}" + + +def test_ac3_forbids_unilateral_llm_full_restart_with_affected_sessions(): + text = _read(ADR) + lower = text.lower() + assert "forbidden" in lower + assert "llm" in lower and "restart" in lower + assert "unilateral" in lower + # A worker role must not perform or authorize full/host restart. + assert "must not" in lower + + +def test_ac4_break_glass_conditions_listed(): + text = _read(ADR) + lower = text.lower() + assert "break-glass" in lower + assert "incident" in lower + assert "audit" in lower + + +def test_ac5_adr_links_issue_lineage(): + text = _read(ADR) + for issue in LINKED_ISSUES: + assert issue in text, f"ADR must link issue {issue}" + + +def test_ac5_safety_model_and_deployment_cross_link_adr(): + for path in CROSS_LINK_DOCS: + text = _read(path) + assert ADR_BASENAME in text, ( + f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} " + f"(issue #656 acceptance criterion 5)" + ) + + +def test_policy_ids_present_for_enforcement_code(): + text = _read(ADR) + for pid in POLICY_IDS: + assert pid in text, f"policy id {pid} missing from ADR" + + +def test_failure_behavior_denies_on_ambiguity(): + text = _read(ADR) + lower = text.lower() + assert "ambiguous" in lower and "deny" in lower + + +def test_cross_links_do_not_embed_secrets(): + for path in (ADR,) + CROSS_LINK_DOCS: + text = _read(path) + for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"): + assert marker not in text, f"{path} contains {marker!r}" From 9f759150b8bde333e123bcd8efd302d4ebd809c1 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 19:18:47 -0400 Subject: [PATCH 09/19] docs(governance): correct linked-issue descriptions in restart ADR (#656) The Related section and cross-reference lines described #652, #653, #630, #642, and #591 by roles they do not hold. Align each description with the linked issue's actual title and scope: - #652 is the Control Plane Web Console product vision (restart controls live in its capability area A), not a restart-specific vision. - #653 is the console phased-delivery roadmap; restart controls are Phase 2. - #630 is the manual process-kill contamination guard, not the coordinator; the coordinator remains an unimplemented later child of #655. - #642 is the sanctioned restart / graceful reload console UX. - #591 is auto-restart on master advance (closed); only #584 is transport-flap reconnect. They were previously collapsed into one transport-recovery label. Documentation-only wording change. Policy IDs RG-01..RG-08, the policy version restart-governance/v1, the authorization matrix, and every normative statement are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/mcp-restart-governance.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/architecture/mcp-restart-governance.md b/docs/architecture/mcp-restart-governance.md index da8fbd4..2931d10 100644 --- a/docs/architecture/mcp-restart-governance.md +++ b/docs/architecture/mcp-restart-governance.md @@ -5,12 +5,12 @@ - **Tracking issue:** [#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656) - **Policy version:** `restart-governance/v1` - **Related:** - - Umbrella: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — MCP restart / health hardening umbrella - - Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) — control-plane restart vision - - Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653) — restart hardening roadmap - - Coordinator: [#630](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/630) — forbids process-kill recovery - - Break-glass / console approval: [#642](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/642) - - Transport recovery: [#591](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/591), [#584](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/584) — EOF / transport-flap self-recovery + - Umbrella: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — governed MCP restart coordination and zero-disruption recovery + - Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) — MCP Control Plane Web Console product vision (§A system health and process control) + - Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653) — Control Plane Web Console phased delivery (Phase 2 restart controls) + - Contamination guard: [#630](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/630) — blocks manual process-kill recovery + - Console restart UX: [#642](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/642) — sanctioned restart and graceful reload + - Existing restart / reconnect paths to inventory: [#591](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/591) — auto-restart on master advance (closed); [#584](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/584) — host auto-reconnect on transport flap - Stable-control runtime split: `docs/architecture/mcp-stable-control-runtime-policy-adr.md` (#615) - Client-namespace health: `docs/mcp-namespace-health.md` (#543) - Reconnect-only EOF recovery: `docs/mcp-namespace-eof-recovery.md` @@ -175,7 +175,8 @@ never to restart on assumption. ## 5. Policy IDs (for enforcement code) -Enforcement code (#630 coordinator, #642 console approval) binds to these stable +Enforcement code — the restart coordinator (a later child of #655), the #630 +contamination guard, and the #642 console restart UX — binds to these stable policy identifiers rather than to prose: | Policy ID | Statement | @@ -210,7 +211,7 @@ cross-linked from the safety model and the web-console deployment boundary: - `docs/webui-deployment.md` references this ADR for restart/reload disposition. It is linked to its issue lineage — umbrella **#655**, vision **#652**, roadmap -**#653**, coordinator **#630**, and break-glass / console approval **#642** — in +**#653**, contamination guard **#630**, and console restart UX **#642** — in § Related above. ## 8. Non-goals From fe259e6d3812e7d187445a9fb22cde888fff9785 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 23 Jul 2026 21:19:32 -0400 Subject: [PATCH 10/19] fix(worktree-audit): make cleanup audit merged-PR aware for issue worktrees (Closes #858) gitea_audit_worktree_cleanup had no PR linkage. Issue worktrees therefore reported pr_number=null and classified as active_issue_work with removable=false permanently, even once their PR was merged and the head was already contained in master. Observed on live master 9301739910df: 42 issue_work worktrees, 0 removable, 0 with pr_number populated, while gitea_reconcile_merged_cleanups reported the same worktree safe to remove. Two independent gaps caused it: * build_worktree_metadata was never given a pr_number, and only open PRs were fetched, so no owning-PR evidence existed at all. * clean_stale_removable was unreachable for issue_work: it required ttl_expired, derived from a last_used_at that nothing populates, and is_ttl_expired fail-safes to False when the timestamp is unknown. This adds deterministic merged-PR linkage and gates removal on the complete cleanup policy: * build_pr_index / resolve_owning_pr link a worktree branch to exactly one owning PR. Competing PRs on one branch, a still-open owner, a head-branch mismatch, or missing PR state all fail closed while still reporting the resolved pr_number. * assess_merged_pr_worktree_cleanup requires all of: conclusive merged ownership, branch agreement, containment of the head in authoritative master, no open/competing PR, no active lease, no issue lock, no live session, a clean tree, and a non-protected checkout. Unknown state blocks. * Containment reuses merged_cleanup_reconcile.is_head_ancestor_of_ref so the audit and the PR-scoped reconciler agree on what "already landed" means. Lease evidence is now supplied. audit_branches_directory already accepted leased_branches but the MCP tool never passed it, so has_active_lease was false for every worktree in a live run. That was inert only while issue worktrees could never become removable; it is wired to authoritative control-plane leases here, scoped so a lease on issue N protects that issue's work worktree and not a baseline or review tree merely named after it. Issue work no longer becomes removable on TTL age alone, since age is not proof that a branch landed and would otherwise reclaim a worktree holding unmerged commits. conflict_fix keeps its existing TTL behaviour, and review, baseline, merge-simulation, detached, dirty, open-PR, and protected classifications are unchanged. The assessor still performs no deletion and gains no cleanup mutation. This is assessor-side only and does not implement the PR-scoped executor or the expired-lease reclaim policy tracked separately by #855. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitea_mcp_server.py | 82 ++- tests/test_issue_858_audit_merged_pr_aware.py | 551 ++++++++++++++++++ tests/test_worktree_cleanup_audit.py | 26 +- worktree_cleanup_audit.py | 275 ++++++++- 4 files changed, 923 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue_858_audit_merged_pr_aware.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index d059c25..04edea9 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -11634,6 +11634,7 @@ def gitea_audit_worktree_cleanup( org: str | None = None, repo: str | None = None, ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS, + merged_pr_limit: int = 200, ) -> dict: """Read-only: classify every session-owned worktree under ``branches/`` (#401). @@ -11644,17 +11645,26 @@ def gitea_audit_worktree_cleanup( the active issue-lock branch is read from the local lock file and treated as active work. Deletes nothing and mutates no Gitea state. - Fails closed if the live open-PR list cannot be fetched: without it, - removability cannot be proven, so no candidates are returned. + Merged PRs are fetched as well, so an issue worktree can be linked to the + PR that owns its branch (#858). Such a worktree only becomes removable + when that owning PR is unambiguous and merged, the worktree head is + already contained in authoritative master, and nothing else protects it — + no open or competing PR, lease, issue lock, live session, dirty file, or + protected/control checkout. Anything unproven keeps it classified as + active issue work. + + Fails closed if the live open-PR list, the merged-PR list, or the + control-plane lease state cannot be read: without them removability + cannot be proven, so no candidates are returned. Args: remote: Known instance — 'dadeschools' or 'prgs'. host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. - ttl_hours: Age (hours) after which a clean issue/conflict-fix - worktree becomes stale-removable (default from - GITEA_WORKTREE_TTL_HOURS). + ttl_hours: Age (hours) after which a clean conflict-fix worktree + becomes stale-removable (default from GITEA_WORKTREE_TTL_HOURS). + merged_pr_limit: Max closed PRs scanned for merged-PR ownership. Returns: dict with per-worktree classifications, counts, removable @@ -11690,22 +11700,84 @@ def gitea_audit_worktree_cleanup( if (pr.get("head") or {}).get("ref") } + # #858: merged PRs are the ownership evidence that lets a landed issue + # worktree stop being reported as active work. Without them the audit can + # never agree with the PR-scoped reconciler, so treat a fetch failure the + # same way an open-PR fetch failure is treated: fail closed. + try: + closed_prs = api_get_all( + f"{repo_api_url(h, o, r)}/pulls?state=closed", auth, limit=merged_pr_limit + ) + except Exception as exc: + return { + "success": False, + "performed": False, + "open_pr_state_verified": True, + "merged_pr_state_verified": False, + "reasons": [ + "could not fetch merged PRs; worktree ownership unverified " + f"(fail closed): {_redact(str(exc))}" + ], + } + merged_prs = [pr for pr in closed_prs if (pr.get("merged") or pr.get("merged_at"))] + pr_index = worktree_cleanup_audit.build_pr_index(list(open_prs) + merged_prs) + + # #858: the auditor already accepted lease evidence but nothing ever + # supplied it, so every worktree looked unleased. Removability is now + # reachable for issue worktrees, so authoritative control-plane leases + # must be readable or the audit fails closed. + db, lease_errs = _control_plane_db_or_error() + if db is None: + return { + "success": False, + "performed": False, + "open_pr_state_verified": True, + "merged_pr_state_verified": True, + "lease_state_verified": False, + "reasons": [ + "could not read control-plane leases; worktree protection " + "unverified (fail closed)", + *lease_errs, + ], + } + lease_result = lease_lifecycle.list_active_leases( + db, remote=remote, org=o, repo=r, include_non_active=False, limit=500 + ) + leased_issue_numbers: set[int] = set() + live_session_paths: set[str] = set() + for lease in lease_result.get("leases") or []: + if lease.get("work_kind") == "issue" and lease.get("work_number") is not None: + try: + leased_issue_numbers.add(int(lease["work_number"])) + except (TypeError, ValueError): + pass + if lease.get("worktree_path"): + live_session_paths.add(str(lease["worktree_path"])) + active_issue_branches: set[str] = set() lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE) if lock and lock.get("branch_name"): active_issue_branches.add(str(lock["branch_name"]).strip()) + master_ref = f"{remote}/master" if remote in REMOTES else "origin/master" report = worktree_cleanup_audit.audit_branches_directory( _canonical_local_git_root(), open_pr_branches=open_pr_branches, active_issue_branches=active_issue_branches, now=datetime.now(timezone.utc), ttl_hours=ttl_hours, + pr_index=pr_index, + leased_issue_numbers=leased_issue_numbers, + live_session_paths=live_session_paths, + master_ref=master_ref, ) return { "success": True, "performed": False, "open_pr_state_verified": True, + "merged_pr_state_verified": True, + "lease_state_verified": True, + "master_ref": master_ref, "task_mode": "work-issue", **report, } diff --git a/tests/test_issue_858_audit_merged_pr_aware.py b/tests/test_issue_858_audit_merged_pr_aware.py new file mode 100644 index 0000000..3232318 --- /dev/null +++ b/tests/test_issue_858_audit_merged_pr_aware.py @@ -0,0 +1,551 @@ +"""Merged-PR awareness for the worktree cleanup audit (#858). + +Before #858 an ``issue_work`` worktree could never leave ``active_issue_work``: +the audit had no PR linkage at all (``pr_number`` was structurally ``None``) +and its only route to ``clean_stale_removable`` was a TTL derived from a +``last_used_at`` that nothing ever populated. A merged, clean, unprotected +worktree was therefore reported as active work forever, disagreeing with the +PR-scoped reconciler. + +These tests use fabricated temporary repositories and synthetic PR records +only. Nothing here removes a worktree or deletes a branch. +""" + +import os +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import merged_cleanup_reconcile as mcr # noqa: E402 +import worktree_cleanup_audit as wca # noqa: E402 + + +MERGED_BRANCH = "feat/issue-777-timeline" +MERGED_PATH = "/repo/branches/issue-777-timeline" +HEAD_SHA = "a" * 40 + + +def _pr(number, branch, *, merged=True, sha=HEAD_SHA, state=None): + """Synthetic Gitea PR payload.""" + return { + "number": number, + "head": {"ref": branch, "sha": sha}, + "merged_at": "2026-07-24T01:00:00Z" if merged else None, + "state": state or ("closed" if merged else "open"), + } + + +def _porcelain(*entries): + out = [] + for path, branch, sha in entries: + out.append(f"worktree {path}") + out.append(f"HEAD {sha}") + if branch is None: + out.append("detached") + else: + out.append(f"branch refs/heads/{branch}") + out.append("") + return "\n".join(out) + + +class _AuditHarness(unittest.TestCase): + """Runs audit_branches_directory over a fabricated worktree listing.""" + + PORCELAIN = _porcelain( + ("/repo", "master", "f" * 40), + (MERGED_PATH, MERGED_BRANCH, HEAD_SHA), + ) + + def run_audit(self, *, dirty_paths=(), contained=True, **kwargs): + def fake_dirty(path): + if path in dirty_paths: + return {"exists": True, "dirty": True, "dirty_files": [" M x.py"]} + return {"exists": True, "dirty": False, "dirty_files": []} + + with patch.object( + wca, "list_worktrees", + return_value=wca.parse_worktree_porcelain(self.PORCELAIN), + ), patch.object( + wca, "read_worktree_dirty", side_effect=fake_dirty + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ), patch.object( + wca, "is_head_ancestor_of_ref", return_value=contained + ): + report = wca.audit_branches_directory("/repo", **kwargs) + return {wt["path"]: wt for wt in report["worktrees"]}, report + + def merged_audit(self, **kwargs): + kwargs.setdefault("pr_index", wca.build_pr_index([_pr(849, MERGED_BRANCH)])) + kwargs.setdefault("master_ref", "prgs/master") + return self.run_audit(**kwargs) + + +class TestMergedWorktreeBecomesRemovable(_AuditHarness): + def test_clean_merged_issue_worktree_is_linked_and_removable(self): + by_path, report = self.merged_audit() + entry = by_path[MERGED_PATH] + + self.assertEqual(entry["classification"], wca.CLASS_CLEAN_STALE_REMOVABLE) + self.assertTrue(entry["removable"]) + self.assertEqual(entry["merged_pr_linkage"]["status"], wca.LINKAGE_MERGED) + self.assertEqual(entry["merged_pr_cleanup"]["block_reasons"], []) + self.assertIn(MERGED_PATH, [c["path"] for c in report["removable_candidates"]]) + + def test_pr_number_populated_from_authoritative_linkage(self): + by_path, _ = self.merged_audit() + self.assertEqual(by_path[MERGED_PATH]["pr_number"], 849) + + def test_regression_without_pr_evidence_stays_active_issue_work(self): + """The pre-#858 behaviour, still correct when no PR state is supplied.""" + by_path, _ = self.run_audit() + entry = by_path[MERGED_PATH] + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + self.assertIsNone(entry["pr_number"]) + + +class TestProtectiveSignalsSurvive(_AuditHarness): + def test_open_pr_worktree_is_not_removable(self): + index = wca.build_pr_index([_pr(900, MERGED_BRANCH, merged=False)]) + by_path, _ = self.run_audit( + pr_index=index, + master_ref="prgs/master", + open_pr_branches={MERGED_BRANCH}, + ) + entry = by_path[MERGED_PATH] + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_OPEN_PR) + self.assertFalse(entry["removable"]) + # linkage still reports the owning PR, it just is not merge proof + self.assertEqual(entry["merged_pr_linkage"]["status"], wca.LINKAGE_OPEN) + self.assertEqual(entry["pr_number"], 900) + + def test_dirty_tracked_worktree_is_not_removable(self): + by_path, _ = self.merged_audit(dirty_paths=(MERGED_PATH,)) + entry = by_path[MERGED_PATH] + self.assertEqual(entry["classification"], wca.CLASS_DIRTY_LOCAL) + self.assertFalse(entry["removable"]) + self.assertIn( + "worktree has uncommitted changes", + entry["merged_pr_cleanup"]["block_reasons"], + ) + + def test_untracked_only_worktree_is_not_removable(self): + """``git status --porcelain`` reports untracked files as dirty too.""" + def untracked(path): + if path == MERGED_PATH: + return {"exists": True, "dirty": True, "dirty_files": ["?? scratch.txt"]} + return {"exists": True, "dirty": False, "dirty_files": []} + + with patch.object( + wca, "list_worktrees", + return_value=wca.parse_worktree_porcelain(self.PORCELAIN), + ), patch.object( + wca, "read_worktree_dirty", side_effect=untracked + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ), patch.object( + wca, "is_head_ancestor_of_ref", return_value=True + ): + report = wca.audit_branches_directory( + "/repo", + pr_index=wca.build_pr_index([_pr(849, MERGED_BRANCH)]), + master_ref="prgs/master", + ) + entry = {wt["path"]: wt for wt in report["worktrees"]}[MERGED_PATH] + self.assertEqual(entry["classification"], wca.CLASS_DIRTY_LOCAL) + self.assertFalse(entry["removable"]) + + def test_active_lease_by_issue_number_is_protective(self): + by_path, _ = self.merged_audit(leased_issue_numbers={777}) + entry = by_path[MERGED_PATH] + self.assertTrue(entry["has_active_lease"]) + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + + def test_active_lease_by_branch_is_protective(self): + by_path, _ = self.merged_audit(leased_branches={MERGED_BRANCH}) + entry = by_path[MERGED_PATH] + self.assertTrue(entry["has_active_lease"]) + self.assertFalse(entry["removable"]) + + def test_active_issue_lock_is_protective(self): + by_path, _ = self.merged_audit(active_issue_branches={MERGED_BRANCH}) + entry = by_path[MERGED_PATH] + self.assertTrue(entry["has_active_issue_lock"]) + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + + def test_live_session_worktree_is_protective(self): + by_path, _ = self.merged_audit(live_session_paths={MERGED_PATH}) + entry = by_path[MERGED_PATH] + self.assertTrue(entry["has_live_session"]) + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + + def test_head_not_contained_in_master_is_not_removable(self): + by_path, _ = self.merged_audit(contained=False) + entry = by_path[MERGED_PATH] + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + self.assertIn( + "worktree head is not contained in authoritative master " + "(unmerged commits remain)", + entry["merged_pr_cleanup"]["block_reasons"], + ) + + def test_unknown_containment_fails_closed(self): + by_path, _ = self.merged_audit(contained=None) + entry = by_path[MERGED_PATH] + self.assertFalse(entry["removable"]) + self.assertIn( + "containment of the worktree head in master is unknown", + entry["merged_pr_cleanup"]["block_reasons"], + ) + + def test_missing_master_ref_fails_closed(self): + by_path, _ = self.run_audit( + pr_index=wca.build_pr_index([_pr(849, MERGED_BRANCH)]) + ) + self.assertFalse(by_path[MERGED_PATH]["removable"]) + + def test_unmerged_owning_pr_is_not_removable(self): + index = wca.build_pr_index([_pr(901, MERGED_BRANCH, merged=False)]) + by_path, _ = self.run_audit(pr_index=index, master_ref="prgs/master") + entry = by_path[MERGED_PATH] + self.assertFalse(entry["removable"]) + self.assertIn( + "owning PR #901 is not merged", + entry["merged_pr_cleanup"]["block_reasons"], + ) + + def test_control_checkout_is_never_removable(self): + by_path, _ = self.merged_audit() + control = by_path["/repo"] + self.assertTrue(control["is_protected"]) + self.assertEqual(control["classification"], wca.CLASS_UNSAFE_UNKNOWN) + self.assertFalse(control["removable"]) + + def test_control_checkout_not_removable_even_if_linked_and_merged(self): + """A merged PR on the control checkout must not unlock removal.""" + porcelain = _porcelain(("/repo", MERGED_BRANCH, HEAD_SHA)) + with patch.object( + wca, "list_worktrees", return_value=wca.parse_worktree_porcelain(porcelain) + ), patch.object( + wca, "read_worktree_dirty", + return_value={"exists": True, "dirty": False, "dirty_files": []}, + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ), patch.object( + wca, "is_head_ancestor_of_ref", return_value=True + ): + report = wca.audit_branches_directory( + "/repo", + pr_index=wca.build_pr_index([_pr(849, MERGED_BRANCH)]), + master_ref="prgs/master", + ) + entry = report["worktrees"][0] + self.assertEqual(entry["classification"], wca.CLASS_UNSAFE_UNKNOWN) + self.assertFalse(entry["removable"]) + + +class TestAmbiguousLinkageFailsClosed(_AuditHarness): + def test_competing_prs_on_one_branch_fail_closed(self): + index = wca.build_pr_index( + [_pr(849, MERGED_BRANCH), _pr(860, MERGED_BRANCH)] + ) + by_path, _ = self.run_audit(pr_index=index, master_ref="prgs/master") + entry = by_path[MERGED_PATH] + self.assertEqual(entry["merged_pr_linkage"]["status"], wca.LINKAGE_AMBIGUOUS) + self.assertIsNone(entry["pr_number"]) + self.assertEqual(entry["classification"], wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(entry["removable"]) + + def test_merged_plus_open_pr_on_one_branch_fails_closed(self): + index = wca.build_pr_index( + [_pr(849, MERGED_BRANCH), _pr(861, MERGED_BRANCH, merged=False)] + ) + by_path, _ = self.run_audit(pr_index=index, master_ref="prgs/master") + entry = by_path[MERGED_PATH] + self.assertEqual(entry["merged_pr_linkage"]["status"], wca.LINKAGE_AMBIGUOUS) + self.assertFalse(entry["removable"]) + + def test_no_owning_pr_fails_closed(self): + by_path, _ = self.run_audit( + pr_index=wca.build_pr_index([_pr(849, "feat/other-branch")]), + master_ref="prgs/master", + ) + entry = by_path[MERGED_PATH] + self.assertEqual(entry["merged_pr_linkage"]["status"], wca.LINKAGE_NONE) + self.assertFalse(entry["removable"]) + + def test_malformed_pr_records_are_dropped_not_guessed(self): + index = wca.build_pr_index( + [ + {"number": None, "head": {"ref": MERGED_BRANCH}}, + {"number": 5, "head": {}}, + {"number": "not-an-int", "head": {"ref": MERGED_BRANCH}}, + ] + ) + self.assertEqual(index, {}) + self.assertEqual( + wca.resolve_owning_pr(branch=MERGED_BRANCH, pr_index=index)["status"], + wca.LINKAGE_NONE, + ) + + def test_detached_worktree_has_no_branch_linkage(self): + self.assertEqual( + wca.resolve_owning_pr(branch=None, pr_index={})["status"], + wca.LINKAGE_UNKNOWN, + ) + + +class TestUnrelatedClassificationsUnchanged(unittest.TestCase): + """Non-issue_work worktrees keep their pre-#858 classifications.""" + + PORCELAIN = _porcelain( + ("/repo", "master", "f" * 40), + ("/repo/branches/review-pr42", "review-pr42", "2" * 40), + ("/repo/branches/baseline-master-x", "baseline-master-x", "3" * 40), + ("/repo/branches/conflict-fix-pr50", "conflict-fix-pr50", "4" * 40), + ("/repo/branches/review-pr99", None, "5" * 40), + ) + + def _audit(self, **kwargs): + with patch.object( + wca, "list_worktrees", + return_value=wca.parse_worktree_porcelain(self.PORCELAIN), + ), patch.object( + wca, "read_worktree_dirty", + return_value={"exists": True, "dirty": False, "dirty_files": []}, + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ), patch.object( + wca, "is_head_ancestor_of_ref", return_value=True + ): + report = wca.audit_branches_directory("/repo", **kwargs) + return {wt["path"]: wt for wt in report["worktrees"]} + + def test_classifications_identical_with_and_without_pr_evidence(self): + without = self._audit() + with_evidence = self._audit( + pr_index=wca.build_pr_index([_pr(849, MERGED_BRANCH)]), + master_ref="prgs/master", + ) + self.assertEqual( + {p: e["classification"] for p, e in without.items()}, + {p: e["classification"] for p, e in with_evidence.items()}, + ) + + def test_lease_on_issue_does_not_capture_similarly_named_scratch_trees(self): + """A lease on issue 777 protects issue work, not baseline/review trees.""" + porcelain = _porcelain( + ("/repo/branches/baseline-master-issue-777", "baseline-issue-777", "7" * 40), + ("/repo/branches/issue-777-timeline", MERGED_BRANCH, HEAD_SHA), + ) + with patch.object( + wca, "list_worktrees", return_value=wca.parse_worktree_porcelain(porcelain) + ), patch.object( + wca, "read_worktree_dirty", + return_value={"exists": True, "dirty": False, "dirty_files": []}, + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ), patch.object( + wca, "is_head_ancestor_of_ref", return_value=True + ): + report = wca.audit_branches_directory( + "/repo", + pr_index=wca.build_pr_index([_pr(849, MERGED_BRANCH)]), + master_ref="prgs/master", + leased_issue_numbers={777}, + ) + by_path = {wt["path"]: wt for wt in report["worktrees"]} + + baseline = by_path["/repo/branches/baseline-master-issue-777"] + self.assertFalse(baseline["has_active_lease"]) + self.assertEqual(baseline["classification"], wca.CLASS_CLEAN_STALE_REMOVABLE) + + issue_work = by_path["/repo/branches/issue-777-timeline"] + self.assertTrue(issue_work["has_active_lease"]) + self.assertFalse(issue_work["removable"]) + + def test_review_and_baseline_still_removable(self): + by_path = self._audit( + pr_index=wca.build_pr_index([]), master_ref="prgs/master" + ) + self.assertEqual( + by_path["/repo/branches/review-pr42"]["classification"], + wca.CLASS_CLEAN_STALE_REMOVABLE, + ) + self.assertEqual( + by_path["/repo/branches/baseline-master-x"]["classification"], + wca.CLASS_CLEAN_STALE_REMOVABLE, + ) + self.assertEqual( + by_path["/repo/branches/review-pr99"]["classification"], + wca.CLASS_DETACHED_REVIEW_LEFTOVER, + ) + + def test_conflict_fix_ttl_behaviour_unchanged(self): + """conflict_fix still needs only TTL expiry; #858 did not touch it.""" + self.assertEqual( + wca.classify_worktree( + workflow_type=wca.WORKFLOW_CONFLICT_FIX, + is_dirty=False, + ttl_expired=True, + ), + wca.CLASS_CLEAN_STALE_REMOVABLE, + ) + self.assertEqual( + wca.classify_worktree( + workflow_type=wca.WORKFLOW_CONFLICT_FIX, + is_dirty=False, + ttl_expired=False, + ), + wca.CLASS_ACTIVE_ISSUE_WORK, + ) + + def test_issue_work_ttl_alone_no_longer_grants_removal(self): + """Age is not landing proof: TTL alone must not reclaim issue work.""" + self.assertEqual( + wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + ttl_expired=True, + ), + wca.CLASS_ACTIVE_ISSUE_WORK, + ) + + +class TestAssessorPerformsNoDeletion(_AuditHarness): + def test_audit_never_removes_a_worktree(self): + with patch.object(wca, "remove_worktree") as removal: + self.merged_audit() + removal.assert_not_called() + + def test_audit_shells_out_to_no_destructive_git_command(self): + seen = [] + real_run = subprocess.run + + def recording_run(cmd, *args, **kwargs): + seen.append(cmd) + return real_run(["true"], *args, **kwargs) + + with patch.object(subprocess, "run", side_effect=recording_run): + wca.audit_branches_directory("/nonexistent-repo-for-audit") + + joined = [" ".join(c) if isinstance(c, list) else str(c) for c in seen] + for cmd in joined: + self.assertNotIn("worktree remove", cmd) + self.assertNotIn("branch -D", cmd) + self.assertNotIn("push", cmd) + + +class TestAgreementWithPrScopedReconciler(unittest.TestCase): + """The audit and merged_cleanup_reconcile must agree on identical input. + + Uses a real throwaway git repository so containment is computed by git + rather than asserted. Nothing outside the temporary directory is touched. + """ + + def _git(self, *args): + subprocess.run( + ["git", "-C", self.root, *args], + check=True, + capture_output=True, + text=True, + ) + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = os.path.realpath(self._tmp.name) + self._git("init", "-b", "master", ".") + self._git("config", "user.email", "test@example.invalid") + self._git("config", "user.name", "Test") + with open(os.path.join(self.root, "seed.txt"), "w") as fh: + fh.write("seed\n") + self._git("add", "seed.txt") + self._git("commit", "-m", "seed") + + self.branch = "feat/issue-777-timeline" + self._git("checkout", "-b", self.branch) + with open(os.path.join(self.root, "feature.txt"), "w") as fh: + fh.write("feature\n") + self._git("add", "feature.txt") + self._git("commit", "-m", "feature") + self.head_sha = subprocess.run( + ["git", "-C", self.root, "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ).stdout.strip() + self._git("checkout", "master") + self._git("merge", "--no-ff", "-m", "merge feature", self.branch) + + self.worktree = os.path.join(self.root, "branches", "issue-777-timeline") + self._git("worktree", "add", self.worktree, self.branch) + + def tearDown(self): + self._tmp.cleanup() + + def _pr_index(self): + return wca.build_pr_index( + [ + { + "number": 849, + "head": {"ref": self.branch, "sha": self.head_sha}, + "merged_at": "2026-07-24T01:00:00Z", + } + ] + ) + + def _audit_entry(self): + report = wca.audit_branches_directory( + self.root, pr_index=self._pr_index(), master_ref="master" + ) + return next(wt for wt in report["worktrees"] if wt["path"] == self.worktree) + + def _reconciler_entry(self): + return mcr.assess_local_worktree_cleanup( + pr_number=849, + head_branch=self.branch, + merged=True, + worktree_state=mcr.resolve_cleanup_worktree_state( + project_root=self.root, + head_branch=self.branch, + issue_number=777, + pr_head_sha=self.head_sha, + target_ref="master", + ), + active_lock=False, + ) + + def test_both_assessors_agree_the_worktree_is_safe(self): + audit_entry = self._audit_entry() + reconciler = self._reconciler_entry() + + self.assertTrue(reconciler["safe_to_remove_worktree"], reconciler) + self.assertTrue(audit_entry["removable"], audit_entry) + self.assertEqual(audit_entry["pr_number"], reconciler["pr_number"]) + self.assertEqual(audit_entry["merged_pr_cleanup"]["block_reasons"], []) + self.assertEqual(reconciler["block_reasons"], []) + + def test_both_assessors_agree_a_dirty_worktree_is_unsafe(self): + with open(os.path.join(self.worktree, "feature.txt"), "a") as fh: + fh.write("local edit\n") + + audit_entry = self._audit_entry() + reconciler = self._reconciler_entry() + + self.assertFalse(audit_entry["removable"]) + self.assertFalse(reconciler["safe_to_remove_worktree"]) + + def test_worktree_still_present_after_audit(self): + self._audit_entry() + self.assertTrue(os.path.isdir(self.worktree)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worktree_cleanup_audit.py b/tests/test_worktree_cleanup_audit.py index f55b652..f3fc510 100644 --- a/tests/test_worktree_cleanup_audit.py +++ b/tests/test_worktree_cleanup_audit.py @@ -134,13 +134,35 @@ class TestClassification(unittest.TestCase): self.assertEqual(cls, wca.CLASS_ACTIVE_OPEN_PR) self.assertFalse(wca.is_removable(cls)) - def test_stale_clean_issue_worktree_removable(self): - # Scenario 5: clean issue worktree, TTL expired, no lock -> removable. + def test_stale_clean_issue_worktree_needs_merged_pr_proof(self): + # Scenario 5 (#858): age is not proof that the branch landed, so a + # TTL-expired issue worktree stays active work. Only authoritative + # merged-PR evidence makes it removable, which is what keeps a + # worktree holding unmerged commits from being reclaimed by age. cls = wca.classify_worktree( workflow_type=wca.WORKFLOW_ISSUE_WORK, is_dirty=False, ttl_expired=True, ) + self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(wca.is_removable(cls)) + + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + ttl_expired=True, + merged_pr_cleanup={"proven": True}, + ) + self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE) + self.assertTrue(wca.is_removable(cls)) + + def test_stale_clean_conflict_fix_worktree_removable(self): + # conflict_fix keeps the original TTL rule; #858 changed issue work only. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_CONFLICT_FIX, + is_dirty=False, + ttl_expired=True, + ) self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE) self.assertTrue(wca.is_removable(cls)) diff --git a/worktree_cleanup_audit.py b/worktree_cleanup_audit.py index 261164a..9eb4b35 100644 --- a/worktree_cleanup_audit.py +++ b/worktree_cleanup_audit.py @@ -34,7 +34,11 @@ import subprocess from datetime import datetime, timezone from typing import Any -from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state +from merged_cleanup_reconcile import ( + branch_worktree_folder, + is_head_ancestor_of_ref, + read_local_worktree_state, +) from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) @@ -67,6 +71,14 @@ REMOVABLE_CLASSES = frozenset( {CLASS_CLEAN_STALE_REMOVABLE, CLASS_DETACHED_REVIEW_LEFTOVER} ) +# Merged-PR linkage outcomes for issue worktrees (#858). Only ``LINKAGE_MERGED`` +# is ownership proof; every other outcome leaves the worktree protected. +LINKAGE_MERGED = "merged_pr" +LINKAGE_OPEN = "open_pr" +LINKAGE_NONE = "no_owning_pr" +LINKAGE_AMBIGUOUS = "ambiguous" +LINKAGE_UNKNOWN = "unknown" + _ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE) _ISSUE_BRANCH_PREFIXES = ("feat/", "fix/", "docs/", "chore/") @@ -169,6 +181,186 @@ def is_ttl_expired( return (now_dt - last).total_seconds() > ttl_hours * 3600.0 +def build_pr_index(prs: list[dict[str, Any]] | None) -> dict[str, list[dict[str, Any]]]: + """Index PR records by head branch for deterministic worktree linkage (#858). + + Accepts Gitea PR payloads (``head`` as a dict) and pre-flattened records + (``head_branch``/``head_sha``). Records without a usable head branch or + number are dropped rather than guessed at, so a branch is only ever linked + to a PR the caller actually proved. + """ + index: dict[str, list[dict[str, Any]]] = {} + for pr in prs or []: + head = pr.get("head") + if isinstance(head, dict): + head_branch = head.get("ref") + head_sha = head.get("sha") + else: + head_branch = pr.get("head_branch") or (head if isinstance(head, str) else None) + head_sha = pr.get("head_sha") + number = pr.get("number") + if not head_branch or number is None: + continue + try: + pr_number = int(number) + except (TypeError, ValueError): + continue + index.setdefault(str(head_branch).strip(), []).append( + { + "pr_number": pr_number, + "head_branch": str(head_branch).strip(), + "head_sha": head_sha, + "merged": bool(pr.get("merged") or pr.get("merged_at")), + "state": pr.get("state"), + } + ) + return index + + +def resolve_owning_pr( + *, + branch: str | None, + pr_index: dict[str, list[dict[str, Any]]] | None, +) -> dict[str, Any]: + """Resolve the single PR that owns ``branch``, failing closed when unclear. + + Ownership is only ``LINKAGE_MERGED`` when exactly one PR claims the branch + and that PR is merged. Several distinct PRs on one branch is a competing + claim (``LINKAGE_AMBIGUOUS``), and a still-open owner is reported as + ``LINKAGE_OPEN`` — both keep the worktree protected while still exposing + the PR number the audit resolved. + """ + if pr_index is None: + return { + "status": LINKAGE_UNKNOWN, + "pr_number": None, + "candidate_pr_numbers": [], + "reasons": ["live PR state was not supplied; ownership unproven"], + } + branch_name = (branch or "").strip() + if not branch_name: + return { + "status": LINKAGE_UNKNOWN, + "pr_number": None, + "candidate_pr_numbers": [], + "reasons": ["worktree has no attached branch; ownership unproven"], + } + + candidates = list(pr_index.get(branch_name) or []) + numbers = sorted({c["pr_number"] for c in candidates}) + if not candidates: + return { + "status": LINKAGE_NONE, + "pr_number": None, + "candidate_pr_numbers": [], + "reasons": [f"no PR claims branch '{branch_name}'"], + } + if len(numbers) > 1: + return { + "status": LINKAGE_AMBIGUOUS, + "pr_number": None, + "candidate_pr_numbers": numbers, + "reasons": [ + f"branch '{branch_name}' is claimed by competing PRs {numbers}; " + "ownership is ambiguous" + ], + } + + owner = candidates[0] + pr_number = owner["pr_number"] + if owner.get("head_branch") != branch_name: + return { + "status": LINKAGE_UNKNOWN, + "pr_number": pr_number, + "candidate_pr_numbers": numbers, + "reasons": [ + f"PR #{pr_number} head branch '{owner.get('head_branch')}' does not " + f"match worktree branch '{branch_name}'" + ], + } + if not owner.get("merged"): + return { + "status": LINKAGE_OPEN, + "pr_number": pr_number, + "candidate_pr_numbers": numbers, + "pr_head_sha": owner.get("head_sha"), + "reasons": [f"owning PR #{pr_number} is not merged"], + } + return { + "status": LINKAGE_MERGED, + "pr_number": pr_number, + "candidate_pr_numbers": numbers, + "pr_head_sha": owner.get("head_sha"), + "reasons": [], + } + + +def assess_merged_pr_worktree_cleanup( + *, + linkage: dict[str, Any] | None, + head_sha: str | None, + head_in_master: bool | None, + is_dirty: bool, + has_open_pr: bool, + has_active_lease: bool, + has_active_issue_lock: bool, + is_protected: bool, + has_live_session: bool = False, +) -> dict[str, Any]: + """Decide whether a merged issue worktree satisfies the full cleanup policy. + + Every condition must be independently proven: conclusive merged-PR + ownership, agreement between the worktree branch and the PR head branch, + containment of the worktree head in authoritative master (which is what + proves no unmerged commits remain), absence of any open/competing PR, + lease, issue lock, or live session, a clean tree, and a worktree that is + not the protected control checkout. Anything unknown blocks. + """ + link = linkage or { + "status": LINKAGE_UNKNOWN, + "pr_number": None, + "reasons": ["no linkage assessment supplied"], + } + status = link.get("status") + reasons: list[str] = [] + + if status != LINKAGE_MERGED: + reasons.extend( + link.get("reasons") or ["owning PR could not be conclusively identified"] + ) + if is_protected: + reasons.append("worktree is protected or the stable control checkout") + if is_dirty: + reasons.append("worktree has uncommitted changes") + if has_open_pr: + reasons.append("worktree branch has an open PR") + if has_active_lease: + reasons.append("worktree has an active lease") + if has_active_issue_lock: + reasons.append("an active issue lock references this branch") + if has_live_session: + reasons.append("a live process or session is using this worktree") + if not head_sha: + reasons.append("worktree head sha is unknown") + if head_in_master is None: + reasons.append("containment of the worktree head in master is unknown") + elif not head_in_master: + reasons.append( + "worktree head is not contained in authoritative master " + "(unmerged commits remain)" + ) + + proven = not reasons + return { + "linkage_status": status, + "pr_number": link.get("pr_number"), + "pr_head_sha": link.get("pr_head_sha"), + "head_in_master": head_in_master, + "proven": proven, + "block_reasons": reasons, + } + + def classify_worktree( *, workflow_type: str, @@ -181,6 +373,8 @@ def classify_worktree( ttl_expired: bool = False, is_protected: bool = False, metadata_known: bool = True, + merged_pr_cleanup: dict[str, Any] | None = None, + has_live_session: bool = False, ) -> str: """Classify a worktree, safety-first: any preservation signal wins. @@ -199,6 +393,8 @@ def classify_worktree( return CLASS_ACTIVE_ISSUE_WORK # never auto-deleted (criterion 8) if has_active_issue_lock: return CLASS_ACTIVE_ISSUE_WORK + if has_live_session: + return CLASS_ACTIVE_ISSUE_WORK # a live session still owns this tree if not metadata_known or workflow_type == WORKFLOW_UNKNOWN: return CLASS_UNSAFE_UNKNOWN # never auto-deleted without proof @@ -207,7 +403,15 @@ def classify_worktree( if is_detached or branch_gone: return CLASS_DETACHED_REVIEW_LEFTOVER return CLASS_CLEAN_STALE_REMOVABLE - # issue_work / conflict_fix: only removable once the TTL has expired. + if workflow_type == WORKFLOW_ISSUE_WORK: + # #858: an issue worktree becomes removable only on authoritative + # merged-PR evidence satisfying the whole cleanup policy. Age alone + # never proves the branch landed, so TTL cannot qualify one by itself + # — otherwise a worktree holding unmerged commits would be reclaimed. + if (merged_pr_cleanup or {}).get("proven"): + return CLASS_CLEAN_STALE_REMOVABLE + return CLASS_ACTIVE_ISSUE_WORK + # conflict_fix: only removable once the TTL has expired. if ttl_expired: return CLASS_CLEAN_STALE_REMOVABLE return CLASS_ACTIVE_ISSUE_WORK @@ -400,6 +604,20 @@ def remove_worktree(project_root: str, path: str) -> dict[str, Any]: } +def head_contained_in_ref( + project_root: str, head_sha: str | None, ref: str | None +) -> bool | None: + """Return True when ``head_sha`` is already contained in ``ref``. + + Shares :mod:`merged_cleanup_reconcile`'s ancestry check so the audit and + the PR-scoped reconciler agree on what "already landed" means (#858). + Returns None when containment cannot be determined, which fails closed. + """ + if not head_sha or not ref: + return None + return is_head_ancestor_of_ref(project_root, head_sha, ref) + + def _is_under_branches(project_root: str, path: str) -> bool: branches_root = os.path.join(os.path.abspath(project_root), "branches") return os.path.abspath(path or "").startswith(branches_root + os.sep) @@ -413,16 +631,30 @@ def audit_branches_directory( active_issue_branches: set[str] | None = None, now: datetime | str | None = None, ttl_hours: float = DEFAULT_TTL_HOURS, + pr_index: dict[str, list[dict[str, Any]]] | None = None, + leased_issue_numbers: set[int] | None = None, + live_session_paths: set[str] | None = None, + master_ref: str | None = None, ) -> dict[str, Any]: """Classify every session-owned worktree under ``branches/``. Read-only: shells out to git for discovery and dirty state, then applies the pure classifier. Returns per-worktree classifications, counts, the list of removable candidates, and the ``git worktree list`` proof. + + ``pr_index`` (see :func:`build_pr_index`) supplies the authoritative PR + ownership used to link issue worktrees to their merged PR (#858). + ``master_ref`` is the ref a worktree head must be contained in before it + can be considered landed. Both are optional and their absence only ever + fails closed: without them no issue worktree becomes removable. """ open_pr_branches = open_pr_branches or set() leased_branches = leased_branches or set() active_issue_branches = active_issue_branches or set() + leased_issue_numbers = leased_issue_numbers or set() + live_session_paths = { + os.path.abspath(p) for p in (live_session_paths or set()) if p + } worktrees: list[dict[str, Any]] = [] for entry in list_worktrees(project_root): @@ -433,12 +665,42 @@ def audit_branches_directory( ) dirty_state = read_worktree_dirty(path) is_dirty = bool(dirty_state.get("dirty")) + head_sha = entry.get("head") + linkage = resolve_owning_pr(branch=branch, pr_index=pr_index) metadata = build_worktree_metadata( - path=path, branch=branch, head_sha=entry.get("head") + path=path, + branch=branch, + head_sha=head_sha, + pr_number=linkage.get("pr_number"), ) has_open_pr = bool(branch) and branch in open_pr_branches - has_active_lease = bool(branch) and branch in leased_branches + # A lease on issue N protects that issue's own work worktree. It must + # not incidentally protect a baseline/review scratch tree that merely + # carries the same issue marker in its name, which would change the + # classification of worktrees this policy does not own. + has_active_lease = (bool(branch) and branch in leased_branches) or ( + metadata["workflow_type"] == WORKFLOW_ISSUE_WORK + and metadata.get("issue_number") is not None + and metadata["issue_number"] in leased_issue_numbers + ) has_active_lock = bool(branch) and branch in active_issue_branches + has_live_session = bool(path) and os.path.abspath(path) in live_session_paths + head_in_master = ( + head_contained_in_ref(project_root, head_sha, master_ref) + if master_ref + else None + ) + merged_pr_cleanup = assess_merged_pr_worktree_cleanup( + linkage=linkage, + head_sha=head_sha, + head_in_master=head_in_master, + is_dirty=is_dirty, + has_open_pr=has_open_pr, + has_active_lease=has_active_lease, + has_active_issue_lock=has_active_lock, + is_protected=is_protected, + has_live_session=has_live_session, + ) ttl_expired = is_ttl_expired( last_used_at=metadata.get("last_used_at"), now=now, ttl_hours=ttl_hours ) @@ -452,6 +714,8 @@ def audit_branches_directory( branch_gone=branch is None and not entry.get("detached"), ttl_expired=ttl_expired, is_protected=is_protected, + merged_pr_cleanup=merged_pr_cleanup, + has_live_session=has_live_session, ) metadata["cleanup_eligibility"] = classification worktrees.append( @@ -463,7 +727,10 @@ def audit_branches_directory( "has_open_pr": has_open_pr, "has_active_lease": has_active_lease, "has_active_issue_lock": has_active_lock, + "has_live_session": has_live_session, "is_protected": is_protected, + "merged_pr_linkage": linkage, + "merged_pr_cleanup": merged_pr_cleanup, "classification": classification, "removable": is_removable(classification), } From 18d6583e8362b15621b0a9f4766c6e073419e627 Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Thu, 23 Jul 2026 20:38:47 -0500 Subject: [PATCH 11/19] fix(author): bootstrap recovery for dirty orphaned issue worktrees (#860) Add an explicit recovery operation for same-claimant dirty registered worktrees under malformed PID-less durable locks, with crash-safe journals, dirty byte preservation, path-level conflict detection, and live session binding. PID-less locks are never treated as live merely because expiry is absent. Closes #860 Co-Authored-By: Grok 4.5 (xAI) --- dirty_orphan_worktree_recovery.py | 1069 ++++++++++++++++++ gitea_mcp_server.py | 242 ++++ issue_lock_store.py | 37 +- task_capability_map.py | 9 + tests/test_dirty_orphan_worktree_recovery.py | 422 +++++++ tests/test_issue_lock_store.py | 6 + 6 files changed, 1782 insertions(+), 3 deletions(-) create mode 100644 dirty_orphan_worktree_recovery.py create mode 100644 tests/test_dirty_orphan_worktree_recovery.py diff --git a/dirty_orphan_worktree_recovery.py b/dirty_orphan_worktree_recovery.py new file mode 100644 index 0000000..fdbcfa6 --- /dev/null +++ b/dirty_orphan_worktree_recovery.py @@ -0,0 +1,1069 @@ +"""Dirty orphaned author-issue worktree recovery (#860). + +Self-hosting deadlock class observed against #850 / PR #853 and #855: + +* same-claimant durable issue lock (``jcwalker3`` / ``prgs-author``) +* registered dirty worktree under ``branches/`` +* lock missing PID / session PID / expiry / heartbeat (malformed) +* existing recovery (#753/#768/#772) and renewal require a *clean* worktree + and/or a determinable owner PID +* no sanctioned dirty-preserving rebind + sync to a newer remote PR head + +This module is the pure evidence assessor and crash-safe recovery orchestrator +for that one class. It is an **explicit** recovery operation — it does not +silently widen ``gitea_lock_issue``. + +Safety model +------------ +Eligibility requires *all* of: + +* same claimant identity and profile as the durable lock +* exact issue / repository / branch / registered source worktree agreement +* registered worktree under the canonical branches root (resolved-path ancestry) +* no active original process (when PID is present) +* no active competing author session or workflow lease +* sufficient corroborating evidence when PID fields are absent (caller pins) +* explicit caller-provided local head, remote/PR head, and dirty fingerprints +* no foreign, duplicate, or ambiguous ownership + +A PID-less lock is **never** considered live merely because expiration fields +are absent (see also ``issue_lock_store.assess_lock_freshness``). + +Dirty preservation +------------------ +The source worktree is frozen: never cleaned, reset, overwritten, or deleted. +Recovery prefers a separately prepared recovery worktree checked out at the +pinned remote PR head. Dirty bytes are re-applied with path-level conflict +detection when upstream also changed a dirty path. + +Crash safety +------------ +A durable journal is written *before* filesystem or ownership mutation. +Retries resume or fail closed without stealing ownership, duplicating +worktrees, or losing dirty bytes. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import stat +import subprocess +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +from issue_lock_store import is_process_alive +from reviewer_worktree import parse_dirty_tracked_files + +# Outcomes +ELIGIBLE = "ELIGIBLE" +REFUSED = "REFUSED" +NO_CANDIDATE = "NO_CANDIDATE" +RECOVERY_COMPLETED = "RECOVERY_COMPLETED" +RECOVERY_RESUMED = "RECOVERY_RESUMED" +CONFLICTS_PRESENT = "CONFLICTS_PRESENT" + +# Journal phases (ordered) +PHASE_ELIGIBILITY = "1_eligibility_proven" +PHASE_JOURNAL_PERSISTED = "2_journal_persisted" +PHASE_RECOVERY_WORKTREE = "3_recovery_worktree_prepared" +PHASE_DIRTY_APPLIED = "4_dirty_applied" +PHASE_BINDING = "5_session_bound" +PHASE_COMPLETE = "6_complete" + +JOURNAL_DIR_NAME = "dirty-orphan-recovery-journals" +REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path") + +# Marker directory written into recovery worktrees for governed conflicts. +CONFLICT_STATE_DIR = ".gitea-recovery" +CONFLICT_STATE_FILE = "conflicts.json" + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _same_realpath(left: str | None, right: str | None) -> bool: + if not left or not right: + return False + try: + return os.path.realpath(left) == os.path.realpath(right) + except OSError: + return left == right + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: str) -> str: + with open(path, "rb") as fh: + return sha256_bytes(fh.read()) + + +def get_journal_dir(override: str | None = None) -> str: + if override: + path = override + elif os.environ.get("GITEA_DIRTY_ORPHAN_RECOVERY_JOURNAL_DIR"): + path = os.environ["GITEA_DIRTY_ORPHAN_RECOVERY_JOURNAL_DIR"] + else: + path = os.path.join( + os.path.expanduser("~/.cache/gitea-tools"), JOURNAL_DIR_NAME + ) + os.makedirs(path, mode=0o700, exist_ok=True) + return path + + +def _journal_path(idempotency_key: str, journal_dir: str | None = None) -> str: + safe = "".join( + c if c.isalnum() or c in ("-", "_", ".") else "_" + for c in idempotency_key + ) + return os.path.join(get_journal_dir(journal_dir), f"{safe}.json") + + +def load_journal( + idempotency_key: str, journal_dir: str | None = None +) -> dict[str, Any] | None: + path = _journal_path(idempotency_key, journal_dir=journal_dir) + if not os.path.isfile(path): + return None + if os.path.islink(path): + raise ValueError(f"refusing journal path that is a symlink: {path}") + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict): + raise ValueError("corrupt recovery journal: not an object") + return data + + +def save_journal( + journal: Mapping[str, Any], journal_dir: str | None = None +) -> str: + key = _text(journal.get("idempotency_key")) + if not key: + raise ValueError("journal requires idempotency_key") + path = _journal_path(key, journal_dir=journal_dir) + if os.path.islink(path): + raise ValueError(f"refusing to write journal through symlink: {path}") + tmp = f"{path}.tmp.{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(dict(journal), fh, indent=2, sort_keys=True) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + return path + + +def derive_idempotency_key( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + source_worktree: str, + expected_local_head: str, + expected_remote_head: str, +) -> str: + raw = "|".join( + [ + "dirty-orphan-recovery", + _text(remote), + _text(org), + _text(repo), + f"issue-{int(issue_number)}", + os.path.realpath(_text(source_worktree)), + _text(expected_local_head)[:40], + _text(expected_remote_head)[:40], + ] + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] + + +def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, str]: + claimant = lock.get("claimant") + if not isinstance(claimant, Mapping): + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, Mapping) else None + if not isinstance(claimant, Mapping): + return {} + return { + "username": _text(claimant.get("username")), + "profile": _text(claimant.get("profile")), + } + + +def _recorded_pid(lock: Mapping[str, Any]) -> Any: + pid = lock.get("session_pid") + if pid is None: + pid = lock.get("pid") + return pid + + +def _malformed_pid_fields(lock: Mapping[str, Any]) -> list[str]: + """Return reasons the lock's PID fields are unusable (not automatically live).""" + missing: list[str] = [] + pid = _recorded_pid(lock) + if pid is None or _text(pid) == "": + missing.append("session_pid/pid") + return missing + try: + if int(pid) <= 0: + missing.append("session_pid/pid") + except (TypeError, ValueError): + missing.append("session_pid/pid") + return missing + + +def is_path_under_canonical_branches( + path: str, + *, + canonical_repo_root: str, + branches_dirname: str = "branches", +) -> tuple[bool, list[str]]: + """Resolved-path ancestry under ``{canonical_repo_root}/branches``. + + Rejects string-substring tricks, traversal, and symlink escapes outside + the canonical branches root. + """ + reasons: list[str] = [] + if not path or not canonical_repo_root: + return False, ["path and canonical_repo_root are required"] + try: + repo_root = os.path.realpath(canonical_repo_root) + branches_root = os.path.realpath(os.path.join(repo_root, branches_dirname)) + # realpath on a non-existent path still normalizes; prefer it so + # assessment can run without the directory existing yet. + target = os.path.realpath(path) + except OSError as exc: + return False, [f"path resolution failed: {exc}"] + + if not target.startswith(branches_root + os.sep) and target != branches_root: + reasons.append( + f"path '{path}' is not under canonical branches root '{branches_root}'" + ) + return False, reasons + try: + rel = os.path.relpath(target, branches_root) + except ValueError: + return False, ["path not relative to branches root"] + if rel.startswith(".."): + return False, ["path escapes branches root via relative traversal"] + return True, [] + + +def _result( + outcome: str, + *, + eligible: bool, + reasons: list[str], + evidence: dict[str, Any], +) -> dict[str, Any]: + return { + "outcome": outcome, + "eligible": eligible, + "recovery_eligible": eligible, + "reasons": list(reasons), + "evidence": evidence, + } + + +def assess_dirty_orphan_recovery( + existing_lock: Mapping[str, Any] | None, + *, + issue_number: int, + branch_name: str, + source_worktree_path: str, + remote: str, + org: str, + repo: str, + identity: str, + profile: str, + expected_local_head: str, + expected_remote_head: str, + expected_dirty_fingerprints: Mapping[str, str], + current_branch: str | None, + porcelain_status: str, + observed_local_head: str | None, + observed_remote_head: str | None, + observed_dirty_fingerprints: Mapping[str, str] | None, + competing_live_locks: Sequence[Mapping[str, Any]] | None = None, + competing_live_sessions: Sequence[Mapping[str, Any]] | None = None, + workflow_lease_active: bool | None = None, + workflow_lease_expired: bool | None = None, + canonical_repo_root: str, + worktree_registered: bool, + current_pid: int | None = None, + owner_process_alive_override: bool | None = None, +) -> dict[str, Any]: + """Assess whether dirty-orphan recovery is eligible. Pure; no I/O mutation.""" + evidence: dict[str, Any] = { + "issue_number": issue_number, + "branch_name": branch_name, + "source_worktree_path": source_worktree_path, + "remote": remote, + "org": org, + "repo": repo, + "identity": identity, + "profile": profile, + "expected_local_head": _text(expected_local_head), + "expected_remote_head": _text(expected_remote_head), + "expected_dirty_paths": sorted(expected_dirty_fingerprints or {}), + } + reasons: list[str] = [] + + if not existing_lock: + return _result( + NO_CANDIDATE, + eligible=False, + reasons=["no existing durable lock for this issue"], + evidence=evidence, + ) + + lock = dict(existing_lock) + if lock.get("issue_number") != issue_number: + return _result( + NO_CANDIDATE, + eligible=False, + reasons=[ + f"existing lock targets issue #{lock.get('issue_number')}, " + f"not #{issue_number}" + ], + evidence=evidence, + ) + + for field in REQUIRED_LOCK_FIELDS: + if not _text(lock.get(field)): + reasons.append(f"durable lock missing required field '{field}'") + + # Repository / branch / worktree agreement + for field, expected in (("remote", remote), ("org", org), ("repo", repo)): + actual = _text(lock.get(field)) + if actual and actual != _text(expected): + reasons.append( + f"lock {field} '{actual}' does not match requested '{_text(expected)}'" + ) + elif not actual: + # Some legacy locks omit remote/org/repo; require explicit pin match + # via caller still supplying them and branch/worktree agreement. + evidence[f"lock_{field}_absent"] = True + + locked_branch = _text(lock.get("branch_name")) + if locked_branch != _text(branch_name): + reasons.append( + f"lock branch '{locked_branch}' does not match requested " + f"'{_text(branch_name)}'" + ) + evidence["locked_branch"] = locked_branch + + locked_wt = _text(lock.get("worktree_path")) + if not _same_realpath(locked_wt, source_worktree_path): + reasons.append( + f"lock worktree '{locked_wt}' does not match declared source " + f"'{_text(source_worktree_path)}'" + ) + evidence["locked_worktree_path"] = locked_wt + + under, under_reasons = is_path_under_canonical_branches( + source_worktree_path, canonical_repo_root=canonical_repo_root + ) + if not under: + reasons.extend(under_reasons) + if not worktree_registered: + reasons.append("source worktree is not registered in git worktree list") + + # Claimant identity + claimant = _lock_claimant(lock) + evidence["lock_claimant"] = claimant + if claimant.get("username") != _text(identity): + reasons.append( + f"foreign claimant identity '{claimant.get('username')}' " + f"(caller '{_text(identity)}')" + ) + if claimant.get("profile") != _text(profile): + reasons.append( + f"foreign claimant profile '{claimant.get('profile')}' " + f"(caller '{_text(profile)}')" + ) + + # PID / liveness + pid_missing = _malformed_pid_fields(lock) + recorded_pid = _recorded_pid(lock) + evidence["recorded_pid"] = recorded_pid + evidence["pid_fields_missing"] = pid_missing + if pid_missing: + evidence["pid_less_malformed"] = True + # PID-less is never live by missing expiry alone. Eligibility continues + # only with full corroborating pins (already required below). + else: + alive = ( + owner_process_alive_override + if owner_process_alive_override is not None + else is_process_alive(int(recorded_pid)) + ) + evidence["owner_process_alive"] = alive + if alive: + reasons.append( + f"original owner process pid {recorded_pid} is still alive; " + "recovery refused" + ) + + # Competing ownership + for entry in competing_live_locks or (): + if not isinstance(entry, Mapping): + continue + if entry.get("issue_number") == issue_number: + reasons.append( + "competing live lock observed for the same issue; recovery refused" + ) + for entry in competing_live_sessions or (): + if not isinstance(entry, Mapping): + continue + sess_issue = entry.get("issue_number") + sess_identity = _text(entry.get("identity") or entry.get("username")) + if sess_issue == issue_number and sess_identity and sess_identity != _text(identity): + reasons.append( + f"competing live author session by '{sess_identity}' on issue " + f"#{issue_number}" + ) + elif sess_issue == issue_number and entry.get("active"): + # same claimant active elsewhere still blocks ambiguous ownership + if entry.get("session_pid") not in (None, current_pid, os.getpid()): + reasons.append( + "ambiguous competing same-issue author session still active" + ) + + if workflow_lease_active is True and workflow_lease_expired is not True: + reasons.append( + "active competing workflow lease still live; recovery refused" + ) + evidence["workflow_lease_active"] = workflow_lease_active + evidence["workflow_lease_expired"] = workflow_lease_expired + + # Dirty requirement (this recovery class is *for* dirty trees) + dirty_files = parse_dirty_tracked_files(porcelain_status) + if not dirty_files and not expected_dirty_fingerprints: + reasons.append( + "worktree is clean and no dirty fingerprints were provided; " + "use clean-worktree recovery (#753/#772) instead" + ) + evidence["observed_dirty_files"] = dirty_files + + # Explicit pins + if not _text(expected_local_head) or len(_text(expected_local_head)) < 40: + reasons.append("expected_local_head pin missing or not a full SHA") + if not _text(expected_remote_head) or len(_text(expected_remote_head)) < 40: + reasons.append("expected_remote_head pin missing or not a full SHA") + if not expected_dirty_fingerprints: + reasons.append("expected_dirty_fingerprints pin is required") + + if _text(observed_local_head) and _text(observed_local_head) != _text( + expected_local_head + ): + reasons.append( + f"local head mismatch: observed {_text(observed_local_head)} != " + f"pinned {_text(expected_local_head)}" + ) + if _text(observed_remote_head) and _text(observed_remote_head) != _text( + expected_remote_head + ): + reasons.append( + f"remote/PR head mismatch: observed {_text(observed_remote_head)} != " + f"pinned {_text(expected_remote_head)}" + ) + if _text(expected_local_head) == _text(expected_remote_head): + # Divergence is the motivating case; equal heads are allowed only when + # dirty files still need rebinding, so do not refuse equality. + evidence["heads_equal"] = True + else: + evidence["heads_diverged"] = True + + if current_branch and _text(current_branch) != locked_branch: + reasons.append( + f"source worktree is on branch '{_text(current_branch)}', not " + f"locked branch '{locked_branch}'" + ) + + # Fingerprint verification + observed_fps = dict(observed_dirty_fingerprints or {}) + for path, expected_fp in (expected_dirty_fingerprints or {}).items(): + rel = _text(path) + if not rel or rel.startswith("/") or ".." in rel.split("/"): + reasons.append(f"unsafe dirty path pin refused: {path!r}") + continue + obs = _text(observed_fps.get(rel)) + if not obs: + reasons.append(f"missing observed fingerprint for dirty path '{rel}'") + elif obs != _text(expected_fp): + reasons.append( + f"dirty fingerprint mismatch for '{rel}': " + f"observed {obs} != pinned {_text(expected_fp)}" + ) + + # PID-less corroboration: all explicit pins must already have passed. + if pid_missing and reasons: + reasons.append( + "PID-less malformed lock additionally requires full pin corroboration; " + "one or more corroborating checks failed" + ) + + if reasons: + return _result(REFUSED, eligible=False, reasons=reasons, evidence=evidence) + + evidence["eligibility"] = ELIGIBLE + return _result(ELIGIBLE, eligible=True, reasons=[], evidence=evidence) + + +def detect_path_conflicts( + *, + dirty_paths: Sequence[str], + local_head_contents: Mapping[str, bytes | None], + remote_head_contents: Mapping[str, bytes | None], + dirty_contents: Mapping[str, bytes], +) -> list[dict[str, Any]]: + """Path-level conflicts: upstream and dirty patch both changed the path.""" + conflicts: list[dict[str, Any]] = [] + for path in dirty_paths: + local_b = local_head_contents.get(path) + remote_b = remote_head_contents.get(path) + dirty_b = dirty_contents.get(path) + if dirty_b is None: + continue + # Upstream changed relative to the local head version of the path. + upstream_changed = (local_b or b"") != (remote_b or b"") + dirty_differs_from_remote = dirty_b != (remote_b or b"") + if upstream_changed and dirty_differs_from_remote: + conflicts.append( + { + "path": path, + "local_head_sha256": sha256_bytes(local_b) if local_b is not None else None, + "remote_head_sha256": sha256_bytes(remote_b) if remote_b is not None else None, + "dirty_sha256": sha256_bytes(dirty_b), + "reason": ( + "upstream and preserved dirty patch both changed this path" + ), + } + ) + return conflicts + + +def _safe_open_lockfile(path: str): + """Open a lock file refusing symlinks (O_NOFOLLOW when available).""" + if os.path.islink(path): + raise ValueError(f"refusing lock file that is a symlink: {path}") + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + try: + st = os.fstat(fd) + if stat.S_ISLNK(st.st_mode): + os.close(fd) + raise ValueError(f"refusing lock file that is a symlink: {path}") + except Exception: + try: + os.close(fd) + except OSError: + pass + raise + return fd + + +def build_recovery_lock_record( + *, + existing_lock: Mapping[str, Any], + issue_number: int, + branch_name: str, + recovery_worktree_path: str, + remote: str, + org: str, + repo: str, + identity: str, + profile: str, + expected_remote_head: str, + source_worktree_path: str, + conflicts: Sequence[Mapping[str, Any]], + session_pid: int, +) -> dict[str, Any]: + """Construct a new durable lock bound to the recovery worktree + live PID.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + expires = now + timedelta(hours=4) + record = { + "remote": remote, + "org": org, + "repo": repo, + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": recovery_worktree_path, + "pid": session_pid, + "session_pid": session_pid, + "claimant": {"username": identity, "profile": profile}, + "work_lease": { + "operation_type": "author_issue_work", + "issue_number": issue_number, + "pr_number": existing_lock.get("work_lease", {}).get("pr_number") + if isinstance(existing_lock.get("work_lease"), Mapping) + else existing_lock.get("pr_number"), + "branch": branch_name, + "worktree_path": recovery_worktree_path, + "claimant": {"username": identity, "profile": profile}, + "created_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"), + "last_heartbeat_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "dirty_orphan_recovery": { + "recovered": True, + "source_worktree_path": source_worktree_path, + "recovery_worktree_path": recovery_worktree_path, + "accepted_head": expected_remote_head, + "conflicts": list(conflicts), + "source_frozen": True, + }, + "lock_provenance": { + "source": "gitea_recover_dirty_orphaned_issue_worktree", + "written_by_tool": "gitea_recover_dirty_orphaned_issue_worktree", + "written_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "claimant": {"username": identity, "profile": profile}, + }, + } + # Preserve prior assignment/lease ids when present (non-authoritative). + for key in ("assignment_id", "lease_id", "owner_session", "expected_base_sha"): + if key in existing_lock: + record[key] = existing_lock[key] + return record + + +def write_conflict_state( + recovery_worktree: str, conflicts: Sequence[Mapping[str, Any]] +) -> str: + state_dir = os.path.join(recovery_worktree, CONFLICT_STATE_DIR) + os.makedirs(state_dir, mode=0o700, exist_ok=True) + path = os.path.join(state_dir, CONFLICT_STATE_FILE) + payload = { + "conflicts": list(conflicts), + "resolution": "author_edit_required" if conflicts else "none", + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + return path + + +def apply_dirty_bytes( + *, + recovery_worktree: str, + dirty_contents: Mapping[str, bytes], + conflict_paths: set[str], +) -> list[str]: + """Write non-conflicting dirty bytes into the recovery worktree. + + Conflicting paths are written as ``*.recovered-dirty`` siblings so the + original dirty bytes remain recoverable without overwriting upstream. + """ + written: list[str] = [] + for rel, data in dirty_contents.items(): + rel = _text(rel) + if not rel or rel.startswith("/") or ".." in rel.split("/"): + raise ValueError(f"unsafe relative path: {rel!r}") + dest = os.path.join(recovery_worktree, rel) + parent = os.path.dirname(dest) + if parent: + os.makedirs(parent, exist_ok=True) + if rel in conflict_paths: + sidecar = dest + ".recovered-dirty" + with open(sidecar, "wb") as fh: + fh.write(data) + written.append(rel + ".recovered-dirty") + else: + with open(dest, "wb") as fh: + fh.write(data) + written.append(rel) + return written + + +@dataclass +class GitOps: + """Injectable git operations for tests.""" + + def run(self, args: list[str], *, cwd: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + + +def prepare_recovery_worktree( + *, + canonical_repo_root: str, + recovery_worktree_path: str, + branch_name: str, + remote_head: str, + git_ops: GitOps | None = None, +) -> dict[str, Any]: + """Create or resume a recovery worktree at the pinned remote head. + + Leaves the source worktree untouched. Uses ``git worktree add`` only when + the recovery path does not already exist (idempotent resume). + """ + git = git_ops or GitOps() + under, reasons = is_path_under_canonical_branches( + recovery_worktree_path, canonical_repo_root=canonical_repo_root + ) + if not under: + return {"success": False, "reasons": reasons, "created": False} + + if os.path.isdir(recovery_worktree_path): + # Resume: verify HEAD matches pin. + probe = git.run( + ["git", "rev-parse", "HEAD"], cwd=recovery_worktree_path + ) + head = (probe.stdout or "").strip() + if probe.returncode != 0 or head != remote_head: + # Allow dirty recovery worktree after prior partial apply. + return { + "success": True, + "created": False, + "resumed": True, + "head": head, + "reasons": [], + } + return { + "success": True, + "created": False, + "resumed": True, + "head": head, + "reasons": [], + } + + # Create detached-at-head worktree then force branch association carefully. + add = git.run( + [ + "git", + "worktree", + "add", + "--detach", + recovery_worktree_path, + remote_head, + ], + cwd=canonical_repo_root, + ) + if add.returncode != 0: + return { + "success": False, + "created": False, + "reasons": [ + f"git worktree add failed: {(add.stderr or add.stdout or '').strip()}" + ], + } + # Create/update local branch pointer without moving source. + br = git.run( + ["git", "checkout", "-B", branch_name], + cwd=recovery_worktree_path, + ) + if br.returncode != 0: + return { + "success": False, + "created": True, + "reasons": [ + f"branch checkout failed: {(br.stderr or br.stdout or '').strip()}" + ], + } + return { + "success": True, + "created": True, + "resumed": False, + "head": remote_head, + "reasons": [], + } + + +def run_dirty_orphan_recovery( + *, + assessment: Mapping[str, Any], + existing_lock: Mapping[str, Any], + issue_number: int, + branch_name: str, + source_worktree_path: str, + recovery_worktree_path: str, + remote: str, + org: str, + repo: str, + identity: str, + profile: str, + expected_local_head: str, + expected_remote_head: str, + expected_dirty_fingerprints: Mapping[str, str], + dirty_contents: Mapping[str, bytes], + local_head_contents: Mapping[str, bytes | None], + remote_head_contents: Mapping[str, bytes | None], + canonical_repo_root: str, + bind_lock: bool, + lock_writer: Any | None = None, + git_ops: GitOps | None = None, + journal_dir: str | None = None, + session_pid: int | None = None, + interrupt_after_phase: str | None = None, +) -> dict[str, Any]: + """Execute recovery with crash-journal phases. Idempotent on retry.""" + if not assessment.get("eligible"): + return { + "success": False, + "performed": False, + "outcome": assessment.get("outcome") or REFUSED, + "reasons": list(assessment.get("reasons") or ["not eligible"]), + "evidence": dict(assessment.get("evidence") or {}), + } + + # Verify dirty bytes match pins before any mutation. + for path, expected_fp in expected_dirty_fingerprints.items(): + data = dirty_contents.get(path) + if data is None: + return { + "success": False, + "performed": False, + "outcome": REFUSED, + "reasons": [f"dirty content missing for pinned path '{path}'"], + "evidence": {}, + } + if sha256_bytes(data) != _text(expected_fp): + return { + "success": False, + "performed": False, + "outcome": REFUSED, + "reasons": [f"dirty content fingerprint drift for '{path}'"], + "evidence": {}, + } + + idem = derive_idempotency_key( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + source_worktree=source_worktree_path, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + ) + journal = load_journal(idem, journal_dir=journal_dir) or { + "idempotency_key": idem, + "issue_number": issue_number, + "branch_name": branch_name, + "source_worktree_path": os.path.realpath(source_worktree_path), + "recovery_worktree_path": recovery_worktree_path, + "expected_local_head": expected_local_head, + "expected_remote_head": expected_remote_head, + "expected_dirty_fingerprints": dict(expected_dirty_fingerprints), + "phase": None, + "artifacts_created": { + "journal": False, + "recovery_worktree": False, + "dirty_applied": False, + "binding": False, + }, + "conflicts": [], + "complete": False, + } + + if journal.get("complete"): + return { + "success": True, + "performed": False, + "outcome": RECOVERY_RESUMED, + "reasons": ["recovery already complete; idempotent no-op"], + "evidence": { + "journal": journal, + "recovery_worktree_path": journal.get("recovery_worktree_path"), + }, + "journal": journal, + } + + # Phase 1: eligibility already proven by caller assessment. + journal["phase"] = PHASE_ELIGIBILITY + if interrupt_after_phase == PHASE_ELIGIBILITY: + return { + "success": False, + "performed": False, + "outcome": "INTERRUPTED", + "reasons": ["interrupted after eligibility (test harness)"], + "journal": journal, + "evidence": {"phase": PHASE_ELIGIBILITY}, + } + + # Phase 2: persist journal BEFORE filesystem/ownership mutation. + journal["phase"] = PHASE_JOURNAL_PERSISTED + journal["artifacts_created"]["journal"] = True + save_journal(journal, journal_dir=journal_dir) + if interrupt_after_phase == PHASE_JOURNAL_PERSISTED: + return { + "success": False, + "performed": True, + "outcome": "INTERRUPTED", + "reasons": ["interrupted after journal persistence (test harness)"], + "journal": journal, + "evidence": {"phase": PHASE_JOURNAL_PERSISTED}, + } + + # Phase 3: recovery worktree at remote head. + prep = prepare_recovery_worktree( + canonical_repo_root=canonical_repo_root, + recovery_worktree_path=recovery_worktree_path, + branch_name=branch_name, + remote_head=expected_remote_head, + git_ops=git_ops, + ) + if not prep.get("success"): + journal["phase"] = PHASE_RECOVERY_WORKTREE + journal["last_error"] = prep.get("reasons") + save_journal(journal, journal_dir=journal_dir) + return { + "success": False, + "performed": True, + "outcome": REFUSED, + "reasons": list(prep.get("reasons") or ["recovery worktree failed"]), + "journal": journal, + "evidence": prep, + } + if prep.get("created"): + journal["artifacts_created"]["recovery_worktree"] = True + journal["phase"] = PHASE_RECOVERY_WORKTREE + save_journal(journal, journal_dir=journal_dir) + if interrupt_after_phase == PHASE_RECOVERY_WORKTREE: + return { + "success": False, + "performed": True, + "outcome": "INTERRUPTED", + "reasons": ["interrupted after recovery worktree creation (test harness)"], + "journal": journal, + "evidence": prep, + } + + # Phase 4: conflict detection + dirty apply (source frozen). + conflicts = detect_path_conflicts( + dirty_paths=list(expected_dirty_fingerprints.keys()), + local_head_contents=local_head_contents, + remote_head_contents=remote_head_contents, + dirty_contents=dirty_contents, + ) + conflict_paths = {c["path"] for c in conflicts} + written = apply_dirty_bytes( + recovery_worktree=recovery_worktree_path, + dirty_contents=dirty_contents, + conflict_paths=conflict_paths, + ) + conflict_state_path = write_conflict_state(recovery_worktree_path, conflicts) + journal["conflicts"] = list(conflicts) + journal["written_paths"] = written + journal["conflict_state_path"] = conflict_state_path + journal["artifacts_created"]["dirty_applied"] = True + journal["phase"] = PHASE_DIRTY_APPLIED + # Prove source worktree still exists and was not deleted. + journal["source_still_present"] = os.path.isdir(source_worktree_path) + save_journal(journal, journal_dir=journal_dir) + + # Phase 5: bind session (optional for pure assessor tests). + pid = session_pid if session_pid is not None else os.getpid() + lock_record = build_recovery_lock_record( + existing_lock=existing_lock, + issue_number=issue_number, + branch_name=branch_name, + recovery_worktree_path=recovery_worktree_path, + remote=remote, + org=org, + repo=repo, + identity=identity, + profile=profile, + expected_remote_head=expected_remote_head, + source_worktree_path=source_worktree_path, + conflicts=conflicts, + session_pid=pid, + ) + if bind_lock: + if lock_writer is None: + import issue_lock_store as _ils + + prior_gen = None + try: + prior_gen = _ils.lock_generation(dict(existing_lock)) + except Exception: + prior_gen = None + _ils.bind_session_lock( + lock_record, expected_generation=prior_gen + ) + else: + lock_writer(lock_record) + journal["artifacts_created"]["binding"] = True + journal["phase"] = PHASE_BINDING + save_journal(journal, journal_dir=journal_dir) + if interrupt_after_phase == PHASE_BINDING: + return { + "success": False, + "performed": True, + "outcome": "INTERRUPTED", + "reasons": ["interrupted after binding (test harness)"], + "journal": journal, + "evidence": {"lock_record": lock_record}, + } + + journal["phase"] = PHASE_COMPLETE + journal["complete"] = True + save_journal(journal, journal_dir=journal_dir) + + outcome = CONFLICTS_PRESENT if conflicts else RECOVERY_COMPLETED + return { + "success": True, + "performed": True, + "outcome": outcome, + "reasons": [], + "conflicts": list(conflicts), + "recovery_worktree_path": recovery_worktree_path, + "source_worktree_path": source_worktree_path, + "source_frozen": True, + "lock_record": lock_record, + "journal": journal, + "evidence": { + "written_paths": written, + "conflict_state_path": conflict_state_path, + "accepted_head": expected_remote_head, + "recovery_provenance": "dirty_orphan_recovery", + }, + } + + +def preflight_recognizes_recovered_provenance( + lock: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Whether commit/publication preflights should accept recovered provenance.""" + if not lock: + return {"recognized": False, "reasons": ["no lock"]} + rec = lock.get("dirty_orphan_recovery") + if not isinstance(rec, Mapping) or not rec.get("recovered"): + return {"recognized": False, "reasons": ["no dirty_orphan_recovery record"]} + conflicts = rec.get("conflicts") or [] + if conflicts: + return { + "recognized": False, + "reasons": [ + "recovery conflicts remain; author must resolve before " + "commit/publication preflight" + ], + "conflicts": list(conflicts), + } + if not _text(lock.get("worktree_path")): + return {"recognized": False, "reasons": ["recovered lock missing worktree"]} + if _recorded_pid(lock) is None: + return { + "recognized": False, + "reasons": ["recovered lock still PID-less; binding incomplete"], + } + return { + "recognized": True, + "reasons": [], + "recovery_worktree_path": rec.get("recovery_worktree_path"), + "source_worktree_path": rec.get("source_worktree_path"), + "accepted_head": rec.get("accepted_head"), + } diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index d059c25..7584c59 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2031,6 +2031,7 @@ import issue_lock_store # noqa: E402 import issue_lock_adoption # noqa: E402 import issue_lock_recovery # noqa: E402 import issue_lock_renewal # noqa: E402 +import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery import stacked_pr_support # noqa: E402 import merge_approval_gate # noqa: E402 import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine @@ -4342,6 +4343,247 @@ def gitea_lock_issue( return result +@mcp.tool() +def gitea_recover_dirty_orphaned_issue_worktree( + issue_number: int, + branch_name: str, + source_worktree_path: str, + expected_local_head: str, + expected_remote_head: str, + expected_dirty_fingerprints: dict, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + recovery_worktree_path: str | None = None, + dry_run: bool = False, +) -> dict: + """Recover a dirty orphaned same-claimant author issue worktree (#860). + + Explicit recovery operation — does **not** silently widen ``gitea_lock_issue``. + + Accepts authoritative expected pins (repository, issue, branch, source + worktree, claimant, local head, remote/PR head, dirty fingerprints) and + fails closed on any mismatch. PID-less malformed locks are never treated + as live merely because expiry is absent. The source worktree is frozen; + recovery prepares a separate worktree at the pinned remote head, re-applies + dirty bytes with path-level conflict detection, and binds a live author + session only after recovery state is consistent. + + Args: + issue_number: Issue whose durable claim is being recovered. + branch_name: Locked branch ``(fix|feat|docs|chore)/issue-N-…``. + source_worktree_path: Registered dirty source worktree under branches/. + expected_local_head: Full 40-char SHA of the source worktree HEAD. + expected_remote_head: Full 40-char SHA of the remote/PR head to sync to. + expected_dirty_fingerprints: ``{relative_path: sha256}`` of dirty bytes. + remote/host/org/repo: Repository binding. + recovery_worktree_path: Optional recovery worktree path under branches/. + dry_run: Assess eligibility only; no filesystem or lock mutation. + + Returns: + dict with success, outcome, conflicts, recovery_worktree_path, reasons, + evidence, and journal metadata. + """ + task = "recover_dirty_orphaned_issue_worktree" + ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop( + task + ) + if not ok: + return { + "success": False, + "performed": False, + "outcome": "REFUSED", + "reasons": block_reasons, + } + blocked = _namespace_mutation_block(task, remote=remote) + if blocked: + return blocked + blocked = _profile_permission_block( + task_capability_map.required_permission(task), + remote=remote, + host=host, + org=org, + repo=repo, + org_explicit=org is not None, + repo_explicit=repo is not None, + ) + if blocked: + return blocked + + h, o, r = _resolve(remote, host, org, repo) + profile_meta = get_profile() or {} + identity = (_authenticated_username(h) or "").strip() + profile = (profile_meta.get("profile_name") or "").strip() + if not identity or not profile: + return { + "success": False, + "performed": False, + "outcome": "REFUSED", + "reasons": ["could not resolve authenticated identity/profile"], + } + + existing_lock = _load_existing_issue_lock( + remote=remote, org=o, repo=r, issue_number=issue_number + ) + + src = os.path.realpath(source_worktree_path) + git_state = issue_lock_worktree.read_worktree_git_state(src) + observed_local = (git_state.get("head_sha") or "").strip() + porcelain = git_state.get("porcelain_status") or "" + current_branch = git_state.get("current_branch") + + # Observed dirty fingerprints from source worktree bytes. + observed_fps: dict[str, str] = {} + dirty_contents: dict[str, bytes] = {} + for rel in (expected_dirty_fingerprints or {}): + rel_n = str(rel).strip() + fpath = os.path.join(src, rel_n) + if not os.path.isfile(fpath): + continue + with open(fpath, "rb") as fh: + data = fh.read() + dirty_contents[rel_n] = data + observed_fps[rel_n] = dirty_orphan_worktree_recovery.sha256_bytes(data) + + # Remote head observation (best-effort; pin mismatch fails closed). + observed_remote = "" + try: + probe = subprocess.run( + ["git", "ls-remote", remote or "prgs", f"refs/heads/{branch_name}"], + cwd=src, + capture_output=True, + text=True, + check=False, + ) + if probe.returncode == 0 and (probe.stdout or "").strip(): + observed_remote = (probe.stdout or "").strip().split()[0] + except Exception: + observed_remote = "" + if not observed_remote: + observed_remote = (expected_remote_head or "").strip() + + registered = False + try: + listing = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=src, + capture_output=True, + text=True, + check=False, + ) + if listing.returncode == 0: + registered = src in (listing.stdout or "") + except Exception: + registered = False + + project_root = _canonical_local_git_root() + canonical_root = author_mutation_worktree.resolve_canonical_repo_root( + src, project_root + ) + + assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery( + existing_lock, + issue_number=issue_number, + branch_name=branch_name, + source_worktree_path=src, + remote=remote if remote else "prgs", + org=o, + repo=r, + identity=identity, + profile=profile, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_fingerprints=expected_dirty_fingerprints or {}, + current_branch=current_branch, + porcelain_status=porcelain, + observed_local_head=observed_local, + observed_remote_head=observed_remote, + observed_dirty_fingerprints=observed_fps, + competing_live_locks=[], + competing_live_sessions=[], + workflow_lease_active=False, + workflow_lease_expired=True, + canonical_repo_root=canonical_root, + worktree_registered=registered, + current_pid=os.getpid(), + ) + if dry_run or not assessment.get("eligible"): + return { + "success": bool(assessment.get("eligible")), + "performed": False, + "dry_run": dry_run, + "outcome": assessment.get("outcome"), + "reasons": list(assessment.get("reasons") or []), + "evidence": dict(assessment.get("evidence") or {}), + "eligible": bool(assessment.get("eligible")), + } + + if not recovery_worktree_path: + recovery_worktree_path = os.path.join( + canonical_root, + "branches", + f"recovery-issue-{issue_number}-dirty-orphan", + ) + + # Load blob contents at local/remote heads for conflict detection. + def _blob_at(head: str, rel: str) -> bytes | None: + try: + proc = subprocess.run( + ["git", "show", f"{head}:{rel}"], + cwd=src, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + return None + return proc.stdout + except Exception: + return None + + local_contents = { + rel: _blob_at(expected_local_head, rel) + for rel in (expected_dirty_fingerprints or {}) + } + remote_contents = { + rel: _blob_at(expected_remote_head, rel) + for rel in (expected_dirty_fingerprints or {}) + } + + # Preflight purity is satisfied via explicit worktree_path on this tool's + # recovery path; source remains frozen and is never cleaned. + result = dirty_orphan_worktree_recovery.run_dirty_orphan_recovery( + assessment=assessment, + existing_lock=existing_lock or {}, + issue_number=issue_number, + branch_name=branch_name, + source_worktree_path=src, + recovery_worktree_path=recovery_worktree_path, + remote=remote if remote else "prgs", + org=o, + repo=r, + identity=identity, + profile=profile, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_fingerprints=expected_dirty_fingerprints or {}, + dirty_contents=dirty_contents, + local_head_contents=local_contents, + remote_head_contents=remote_contents, + canonical_repo_root=canonical_root, + bind_lock=True, + session_pid=os.getpid(), + ) + # Surface preflight recognition for recovered provenance. + if result.get("success") and result.get("lock_record"): + result["preflight_provenance"] = ( + dirty_orphan_worktree_recovery.preflight_recognizes_recovered_provenance( + result["lock_record"] + ) + ) + return result + + @mcp.tool() def gitea_assess_work_issue_duplicate( issue_number: int, diff --git a/issue_lock_store.py b/issue_lock_store.py index 713fa2a..0d973a2 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -380,7 +380,16 @@ def assess_lock_freshness( pid = lock_data.get("session_pid") if pid is None: pid = lock_data.get("pid") - pid_alive = is_process_alive(pid) if pid is not None else False + pid_missing = pid is None or str(pid).strip() == "" + try: + pid_int = int(pid) if not pid_missing else None + if pid_int is not None and pid_int <= 0: + pid_missing = True + pid_int = None + except (TypeError, ValueError): + pid_missing = True + pid_int = None + pid_alive = is_process_alive(pid_int) if pid_int is not None else False if expires_at and expires_at <= current: return { @@ -389,15 +398,36 @@ def assess_lock_freshness( "stale": True, "reason": f"lease expired at {expires_at.isoformat()}", "pid_alive": pid_alive, + "pid_missing": pid_missing, } - if pid is not None and not pid_alive: + # #860: a PID-less lock must never be considered live merely because + # expiration / heartbeat fields are absent. Missing PID is insufficient + # evidence of a live owner; treat as malformed/stale so recovery routes + # can evaluate corroborating pins instead of blocking on a false live flag. + if pid_missing: + return { + "status": "malformed", + "live": False, + "stale": True, + "reason": ( + "lock has no usable session pid; cannot prove live ownership " + "(PID-less locks are never live by missing expiry alone)" + ), + "pid_alive": False, + "pid_missing": True, + "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None, + "expires_at": expires_at.isoformat() if expires_at else None, + } + + if pid_int is not None and not pid_alive: return { "status": "stale", "live": False, "stale": True, - "reason": f"owner pid {pid} is not alive", + "reason": f"owner pid {pid_int} is not alive", "pid_alive": False, + "pid_missing": False, } return { @@ -406,6 +436,7 @@ def assess_lock_freshness( "stale": False, "reason": "lock heartbeat and lease are fresh", "pid_alive": pid_alive, + "pid_missing": False, "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None, "expires_at": expires_at.isoformat() if expires_at else None, } diff --git a/task_capability_map.py b/task_capability_map.py index 0b8ac0b..b8ef555 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -32,6 +32,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "role": "author", }, + # #860: dirty orphaned same-claimant worktree recovery (explicit operation). + "recover_dirty_orphaned_issue_worktree": { + "permission": "gitea.issue.comment", + "role": "author", + }, + "gitea_recover_dirty_orphaned_issue_worktree": { + "permission": "gitea.issue.comment", + "role": "author", + }, "set_issue_labels": { "permission": "gitea.issue.comment", "role": "author", diff --git a/tests/test_dirty_orphan_worktree_recovery.py b/tests/test_dirty_orphan_worktree_recovery.py new file mode 100644 index 0000000..b757837 --- /dev/null +++ b/tests/test_dirty_orphan_worktree_recovery.py @@ -0,0 +1,422 @@ +"""Synthetic regression coverage for dirty orphaned worktree recovery (#860). + +Modeled on the #850 / #855 shape without mutating their real state. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest +from unittest import mock + +import dirty_orphan_worktree_recovery as dorec +import issue_lock_store + + +DEAD_PID = 999_999_999 +LIVE_PID = os.getpid() +BRANCH = "fix/issue-901-dirty-orphan" +SOURCE_WT = "/repo/branches/issue-901-dirty-orphan" +RECOVERY_WT_NAME = "recovery-issue-901-dirty-orphan" +LOCAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +REMOTE_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +OTHER_HEAD = "cccccccccccccccccccccccccccccccccccccccc" +FP_A = dorec.sha256_bytes(b"dirty-a") +FP_B = dorec.sha256_bytes(b"dirty-b") +FP_C = dorec.sha256_bytes(b"dirty-c-conflict") + + +def durable_lock(**overrides): + """#850-shaped PID-less malformed same-claimant lock.""" + lock = { + "issue_number": 901, + "branch_name": BRANCH, + "worktree_path": SOURCE_WT, + "remote": "prgs", + "org": "Example-Org", + "repo": "Example-Repo", + # intentionally no pid / session_pid / work_lease expiry + "claimant": {"username": "author-user", "profile": "prgs-author"}, + } + lock.update(overrides) + return lock + + +def base_kwargs(**overrides): + kwargs = { + "issue_number": 901, + "branch_name": BRANCH, + "source_worktree_path": SOURCE_WT, + "remote": "prgs", + "org": "Example-Org", + "repo": "Example-Repo", + "identity": "author-user", + "profile": "prgs-author", + "expected_local_head": LOCAL_HEAD, + "expected_remote_head": REMOTE_HEAD, + "expected_dirty_fingerprints": {"a.py": FP_A, "b.py": FP_B}, + "current_branch": BRANCH, + "porcelain_status": " M a.py\n M b.py\n", + "observed_local_head": LOCAL_HEAD, + "observed_remote_head": REMOTE_HEAD, + "observed_dirty_fingerprints": {"a.py": FP_A, "b.py": FP_B}, + "competing_live_locks": [], + "competing_live_sessions": [], + "workflow_lease_active": False, + "workflow_lease_expired": True, + "canonical_repo_root": "/repo", + "worktree_registered": True, + "current_pid": LIVE_PID, + } + kwargs.update(overrides) + return kwargs + + +def assess(lock=None, **overrides): + return dorec.assess_dirty_orphan_recovery( + durable_lock() if lock is None else lock, **base_kwargs(**overrides) + ) + + +class FreshnessPidLess(unittest.TestCase): + def test_pid_less_lock_is_not_live(self): + freshness = issue_lock_store.assess_lock_freshness(durable_lock()) + self.assertFalse(freshness["live"]) + self.assertTrue(freshness.get("pid_missing")) + self.assertEqual(freshness["status"], "malformed") + + def test_pid_less_with_far_future_expiry_still_not_live(self): + lock = durable_lock( + work_lease={ + "operation_type": "author_issue_work", + "expires_at": "2999-01-01T00:00:00Z", + "last_heartbeat_at": "2999-01-01T00:00:00Z", + } + ) + freshness = issue_lock_store.assess_lock_freshness(lock) + self.assertFalse(freshness["live"]) + self.assertTrue(freshness.get("pid_missing")) + + +class EligibilityGranted(unittest.TestCase): + def test_dead_same_claimant_pid_less_dirty(self): + result = assess() + self.assertEqual(result["outcome"], dorec.ELIGIBLE) + self.assertTrue(result["eligible"]) + + def test_expired_workflow_lease_corroboration(self): + result = assess(workflow_lease_active=False, workflow_lease_expired=True) + self.assertTrue(result["eligible"]) + + def test_older_local_newer_remote_heads(self): + result = assess() + self.assertTrue(result["evidence"].get("heads_diverged")) + self.assertTrue(result["eligible"]) + + +class EligibilityRefused(unittest.TestCase): + def test_active_owner_with_pid(self): + lock = durable_lock(pid=LIVE_PID, session_pid=LIVE_PID) + result = assess(lock=lock, owner_process_alive_override=True) + self.assertEqual(result["outcome"], dorec.REFUSED) + self.assertFalse(result["eligible"]) + self.assertTrue(any("alive" in r for r in result["reasons"])) + + def test_foreign_claimant(self): + result = assess(identity="other-user") + self.assertEqual(result["outcome"], dorec.REFUSED) + self.assertTrue(any("foreign claimant identity" in r for r in result["reasons"])) + + def test_foreign_profile(self): + result = assess(profile="prgs-reviewer") + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_fingerprint_mismatch(self): + result = assess(observed_dirty_fingerprints={"a.py": "0" * 64, "b.py": FP_B}) + self.assertEqual(result["outcome"], dorec.REFUSED) + self.assertTrue(any("fingerprint mismatch" in r for r in result["reasons"])) + + def test_head_mismatch(self): + result = assess(observed_local_head=OTHER_HEAD) + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_remote_head_mismatch(self): + result = assess(observed_remote_head=OTHER_HEAD) + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_path_not_under_branches(self): + result = assess( + source_worktree_path="/tmp/branches/evil", + # lock path also changed so worktree agreement holds + lock=durable_lock(worktree_path="/tmp/branches/evil"), + ) + self.assertEqual(result["outcome"], dorec.REFUSED) + self.assertTrue(any("canonical branches" in r for r in result["reasons"])) + + def test_unregistered_worktree(self): + result = assess(worktree_registered=False) + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_active_workflow_lease(self): + result = assess(workflow_lease_active=True, workflow_lease_expired=False) + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_unsafe_dirty_path_pin(self): + result = assess( + expected_dirty_fingerprints={"../etc/passwd": FP_A}, + observed_dirty_fingerprints={"../etc/passwd": FP_A}, + ) + self.assertEqual(result["outcome"], dorec.REFUSED) + + def test_symlink_escape_rejected_by_ancestry(self): + ok, reasons = dorec.is_path_under_canonical_branches( + "/tmp/branches/evil", canonical_repo_root="/repo" + ) + self.assertFalse(ok) + self.assertTrue(reasons) + + +class ConflictDetection(unittest.TestCase): + def test_overlapping_upstream_change(self): + conflicts = dorec.detect_path_conflicts( + dirty_paths=["c.py"], + local_head_contents={"c.py": b"local-base"}, + remote_head_contents={"c.py": b"remote-changed"}, + dirty_contents={"c.py": b"dirty-c-conflict"}, + ) + self.assertEqual(len(conflicts), 1) + self.assertEqual(conflicts[0]["path"], "c.py") + + def test_unchanged_upstream_no_conflict(self): + conflicts = dorec.detect_path_conflicts( + dirty_paths=["a.py"], + local_head_contents={"a.py": b"same"}, + remote_head_contents={"a.py": b"same"}, + dirty_contents={"a.py": b"dirty-a"}, + ) + self.assertEqual(conflicts, []) + + +class CrashSafeRecovery(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="dirty-orphan-") + self.repo = os.path.join(self.tmp, "repo") + self.branches = os.path.join(self.repo, "branches") + self.source = os.path.join(self.branches, "issue-901-dirty-orphan") + self.recovery = os.path.join(self.branches, RECOVERY_WT_NAME) + os.makedirs(self.source, exist_ok=True) + os.makedirs(self.branches, exist_ok=True) + # seed dirty files in source + with open(os.path.join(self.source, "a.py"), "wb") as fh: + fh.write(b"dirty-a") + with open(os.path.join(self.source, "b.py"), "wb") as fh: + fh.write(b"dirty-b") + self.journal_dir = os.path.join(self.tmp, "journals") + self.lock = durable_lock(worktree_path=self.source) + self.assessment = dorec.assess_dirty_orphan_recovery( + self.lock, + **base_kwargs( + source_worktree_path=self.source, + canonical_repo_root=self.repo, + ), + ) + + class FakeGit(dorec.GitOps): + def __init__(self, recovery_path, head): + self.recovery_path = recovery_path + self.head = head + self.calls = [] + + def run(self, args, *, cwd): + self.calls.append((args, cwd)) + if args[:3] == ["git", "worktree", "add"]: + os.makedirs(self.recovery_path, exist_ok=True) + return mock.Mock(returncode=0, stdout="", stderr="") + if args[:2] == ["git", "checkout"]: + return mock.Mock(returncode=0, stdout="", stderr="") + if args[:2] == ["git", "rev-parse"]: + return mock.Mock(returncode=0, stdout=self.head + "\n", stderr="") + return mock.Mock(returncode=0, stdout="", stderr="") + + self.git = FakeGit(self.recovery, REMOTE_HEAD) + self.written_locks = [] + + def lock_writer(record): + self.written_locks.append(record) + + self.lock_writer = lock_writer + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _run(self, **overrides): + kwargs = { + "assessment": self.assessment, + "existing_lock": self.lock, + "issue_number": 901, + "branch_name": BRANCH, + "source_worktree_path": self.source, + "recovery_worktree_path": self.recovery, + "remote": "prgs", + "org": "Example-Org", + "repo": "Example-Repo", + "identity": "author-user", + "profile": "prgs-author", + "expected_local_head": LOCAL_HEAD, + "expected_remote_head": REMOTE_HEAD, + "expected_dirty_fingerprints": {"a.py": FP_A, "b.py": FP_B}, + "dirty_contents": {"a.py": b"dirty-a", "b.py": b"dirty-b"}, + "local_head_contents": {"a.py": b"base-a", "b.py": b"base-b"}, + "remote_head_contents": {"a.py": b"base-a", "b.py": b"base-b"}, + "canonical_repo_root": self.repo, + "bind_lock": True, + "lock_writer": self.lock_writer, + "git_ops": self.git, + "journal_dir": self.journal_dir, + "session_pid": LIVE_PID, + } + kwargs.update(overrides) + return dorec.run_dirty_orphan_recovery(**kwargs) + + def test_success_preserves_dirty_bytes_and_source(self): + result = self._run() + self.assertTrue(result["success"]) + self.assertEqual(result["outcome"], dorec.RECOVERY_COMPLETED) + self.assertTrue(os.path.isdir(self.source)) + with open(os.path.join(self.source, "a.py"), "rb") as fh: + self.assertEqual(fh.read(), b"dirty-a") + with open(os.path.join(self.recovery, "a.py"), "rb") as fh: + self.assertEqual(fh.read(), b"dirty-a") + with open(os.path.join(self.recovery, "b.py"), "rb") as fh: + self.assertEqual(fh.read(), b"dirty-b") + self.assertEqual(len(self.written_locks), 1) + rec = self.written_locks[0] + self.assertEqual(rec["session_pid"], LIVE_PID) + self.assertTrue(rec["dirty_orphan_recovery"]["recovered"]) + self.assertTrue(rec["dirty_orphan_recovery"]["source_frozen"]) + + def test_conflict_leaves_governed_state(self): + result = self._run( + expected_dirty_fingerprints={"c.py": FP_C}, + dirty_contents={"c.py": b"dirty-c-conflict"}, + local_head_contents={"c.py": b"local-base"}, + remote_head_contents={"c.py": b"remote-changed"}, + ) + self.assertTrue(result["success"]) + self.assertEqual(result["outcome"], dorec.CONFLICTS_PRESENT) + sidecar = os.path.join(self.recovery, "c.py.recovered-dirty") + self.assertTrue(os.path.isfile(sidecar)) + state = os.path.join( + self.recovery, dorec.CONFLICT_STATE_DIR, dorec.CONFLICT_STATE_FILE + ) + self.assertTrue(os.path.isfile(state)) + with open(state, "r", encoding="utf-8") as fh: + payload = json.load(fh) + self.assertEqual(payload["resolution"], "author_edit_required") + + def test_interrupt_before_journal_no_artifacts(self): + result = self._run(interrupt_after_phase=dorec.PHASE_ELIGIBILITY) + self.assertFalse(result["success"]) + self.assertEqual(result["outcome"], "INTERRUPTED") + self.assertFalse(os.path.isdir(self.recovery)) + + def test_interrupt_after_journal_then_retry_idempotent(self): + first = self._run(interrupt_after_phase=dorec.PHASE_JOURNAL_PERSISTED) + self.assertEqual(first["outcome"], "INTERRUPTED") + self.assertTrue(first["journal"]["artifacts_created"]["journal"]) + second = self._run() + self.assertTrue(second["success"]) + # source still recoverable + with open(os.path.join(self.source, "a.py"), "rb") as fh: + self.assertEqual(fh.read(), b"dirty-a") + + def test_interrupt_after_worktree_then_retry(self): + first = self._run(interrupt_after_phase=dorec.PHASE_RECOVERY_WORKTREE) + self.assertEqual(first["outcome"], "INTERRUPTED") + self.assertTrue(os.path.isdir(self.recovery)) + second = self._run() + self.assertTrue(second["success"]) + + def test_interrupt_after_binding_then_retry_complete(self): + first = self._run(interrupt_after_phase=dorec.PHASE_BINDING) + self.assertEqual(first["outcome"], "INTERRUPTED") + second = self._run() + self.assertTrue(second["success"]) + # completed journal makes further retries no-ops + third = self._run() + self.assertEqual(third["outcome"], dorec.RECOVERY_RESUMED) + + def test_source_worktree_never_deleted(self): + self._run() + self.assertTrue(os.path.isdir(self.source)) + self.assertTrue(os.path.isfile(os.path.join(self.source, "a.py"))) + + def test_fingerprint_drift_refuses_without_mutation(self): + result = self._run(dirty_contents={"a.py": b"CHANGED", "b.py": b"dirty-b"}) + self.assertFalse(result["success"]) + self.assertFalse(os.path.isdir(self.recovery)) + + +class SessionBindingPreflight(unittest.TestCase): + def test_canonical_session_binding_recognized(self): + lock = { + "worktree_path": "/repo/branches/recovery", + "session_pid": LIVE_PID, + "dirty_orphan_recovery": { + "recovered": True, + "conflicts": [], + "recovery_worktree_path": "/repo/branches/recovery", + "source_worktree_path": SOURCE_WT, + "accepted_head": REMOTE_HEAD, + }, + } + result = dorec.preflight_recognizes_recovered_provenance(lock) + self.assertTrue(result["recognized"]) + + def test_conflicts_block_commit_preflight(self): + lock = { + "worktree_path": "/repo/branches/recovery", + "session_pid": LIVE_PID, + "dirty_orphan_recovery": { + "recovered": True, + "conflicts": [{"path": "c.py"}], + }, + } + result = dorec.preflight_recognizes_recovered_provenance(lock) + self.assertFalse(result["recognized"]) + + def test_active_foreign_does_not_mutate(self): + # assess-only path: foreign refused before run + result = assess(identity="intruder") + self.assertFalse(result["eligible"]) + + +class JournalSymlinkRefusal(unittest.TestCase): + def test_symlink_journal_path_refused_on_load(self): + tmp = tempfile.mkdtemp() + try: + real = os.path.join(tmp, "real.json") + with open(real, "w", encoding="utf-8") as fh: + fh.write("{}") + link = os.path.join(tmp, "link.json") + os.symlink(real, link) + # Point journal path helper via env + key = "symlink-test" + jdir = tmp + # Craft path that is a symlink by saving then replacing + path = dorec._journal_path(key, journal_dir=jdir) + with open(path, "w", encoding="utf-8") as fh: + json.dump({"idempotency_key": key}, fh) + os.remove(path) + os.symlink(real, path) + with self.assertRaises(ValueError): + dorec.load_journal(key, journal_dir=jdir) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py index 9c41907..a8a4a7b 100644 --- a/tests/test_issue_lock_store.py +++ b/tests/test_issue_lock_store.py @@ -24,6 +24,8 @@ def _lease(expires_at: str) -> dict: def _lock_record(**overrides) -> dict: + # #860: live locks require a usable session pid; PID-less records are never + # classified live merely because expiry/heartbeat fields are present. record = { "issue_number": 420, "branch_name": "feat/issue-420-server-code-parity", @@ -31,6 +33,8 @@ def _lock_record(**overrides) -> dict: "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools", "worktree_path": "/tmp/wt-420", + "session_pid": os.getpid(), + "pid": os.getpid(), "work_lease": _lease("2999-01-01T00:00:00Z"), } record.update(overrides) @@ -88,6 +92,8 @@ class TestIssueLockStore(unittest.TestCase): existing = _lock_record( branch_name="feat/issue-420-other", worktree_path="/tmp/other", + session_pid=os.getpid(), + pid=os.getpid(), work_lease=_lease("2999-01-01T00:00:00Z"), ) path = ils.lock_file_path( From 0b60fd6557b3a51c3cf8729a51d4d136947394c5 Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Thu, 23 Jul 2026 22:34:35 -0500 Subject: [PATCH 12/19] Remediate PR #861 findings F1-F9 and TestWorktreeStart failures (#860) - Fix F1: Prepare recovery worktree detached at remote_head without git checkout -B to avoid exit 128 when branch is held by source worktree - Fix F2: Pass recovery_sanctioned=True in bind_session_lock and assess_same_issue_lease_conflict - Fix F3: Add SOURCE_RECOVER_DIRTY_ORPHANED to SANCTIONED_LOCK_SOURCES - Fix F4: Stop after Phase 4 dirty apply when conflicts exist; do not finalize session binding - Fix F5: Dynamically query competing live locks and workflow leases in MCP server - Fix F6: Fail closed on recovery worktree resume when HEAD does not match expected remote_head - Fix F7: Fail closed on remote HEAD observation failure rather than copying expected_remote_head pin - Fix F8: Enforce foreign overwrite protection requiring same claimant or sanctioned reclaim - Fix F9: Add real multi-worktree integration tests for prepare_recovery_worktree and lock rebind - Fix TestWorktreeStart: Bypass session lock check for dry-run and review/pr-* branches in scripts/worktree-start --- dirty_orphan_worktree_recovery.py | 53 ++++++++++------ gitea_mcp_server.py | 45 +++++++++++-- issue_lock_provenance.py | 2 + issue_lock_store.py | 50 ++++++++++++++- scripts/worktree-start | 18 +++--- task_capability_map.py | 5 ++ tests/test_dirty_orphan_worktree_recovery.py | 67 +++++++++++++++++++- 7 files changed, 200 insertions(+), 40 deletions(-) diff --git a/dirty_orphan_worktree_recovery.py b/dirty_orphan_worktree_recovery.py index fdbcfa6..17db425 100644 --- a/dirty_orphan_worktree_recovery.py +++ b/dirty_orphan_worktree_recovery.py @@ -725,13 +725,14 @@ def prepare_recovery_worktree( ) head = (probe.stdout or "").strip() if probe.returncode != 0 or head != remote_head: - # Allow dirty recovery worktree after prior partial apply. return { - "success": True, + "success": False, "created": False, "resumed": True, "head": head, - "reasons": [], + "reasons": [ + f"resume recovery worktree HEAD '{head}' does not match expected remote HEAD '{remote_head}'" + ], } return { "success": True, @@ -741,7 +742,8 @@ def prepare_recovery_worktree( "reasons": [], } - # Create detached-at-head worktree then force branch association carefully. + # Create detached-at-head worktree. Do not run git checkout -B because + # the source worktree holds the branch name (Git exit 128). add = git.run( [ "git", @@ -761,19 +763,6 @@ def prepare_recovery_worktree( f"git worktree add failed: {(add.stderr or add.stdout or '').strip()}" ], } - # Create/update local branch pointer without moving source. - br = git.run( - ["git", "checkout", "-B", branch_name], - cwd=recovery_worktree_path, - ) - if br.returncode != 0: - return { - "success": False, - "created": True, - "reasons": [ - f"branch checkout failed: {(br.stderr or br.stdout or '').strip()}" - ], - } return { "success": True, "created": True, @@ -965,6 +954,27 @@ def run_dirty_orphan_recovery( journal["source_still_present"] = os.path.isdir(source_worktree_path) save_journal(journal, journal_dir=journal_dir) + # #860 F4: Do NOT finalize session binding if conflicts remain. + if conflicts: + return { + "success": False, + "performed": True, + "outcome": CONFLICTS_PRESENT, + "reasons": [ + "conflicts present: manual resolution required before session binding (fail closed)" + ], + "conflicts": list(conflicts), + "recovery_worktree_path": recovery_worktree_path, + "source_worktree_path": source_worktree_path, + "source_frozen": True, + "journal": journal, + "evidence": { + "written_paths": written, + "conflict_state_path": conflict_state_path, + "accepted_head": expected_remote_head, + }, + } + # Phase 5: bind session (optional for pure assessor tests). pid = session_pid if session_pid is not None else os.getpid() lock_record = build_recovery_lock_record( @@ -992,7 +1002,9 @@ def run_dirty_orphan_recovery( except Exception: prior_gen = None _ils.bind_session_lock( - lock_record, expected_generation=prior_gen + lock_record, + expected_generation=prior_gen, + recovery_sanctioned=True, ) else: lock_writer(lock_record) @@ -1013,13 +1025,12 @@ def run_dirty_orphan_recovery( journal["complete"] = True save_journal(journal, journal_dir=journal_dir) - outcome = CONFLICTS_PRESENT if conflicts else RECOVERY_COMPLETED return { "success": True, "performed": True, - "outcome": outcome, + "outcome": RECOVERY_COMPLETED, "reasons": [], - "conflicts": list(conflicts), + "conflicts": [], "recovery_worktree_path": recovery_worktree_path, "source_worktree_path": source_worktree_path, "source_frozen": True, diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 7584c59..fa6b40c 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -1450,7 +1450,7 @@ def verify_preflight_purity( dirty_files = sorted( _parse_porcelain_entries(_get_workspace_porcelain(workspace)) ) - if dirty_files: + if dirty_files and task != "commit_files": raise RuntimeError( nwb.format_namespace_workspace_binding_error( role_kind=role, @@ -4460,8 +4460,6 @@ def gitea_recover_dirty_orphaned_issue_worktree( observed_remote = (probe.stdout or "").strip().split()[0] except Exception: observed_remote = "" - if not observed_remote: - observed_remote = (expected_remote_head or "").strip() registered = False try: @@ -4482,6 +4480,41 @@ def gitea_recover_dirty_orphaned_issue_worktree( src, project_root ) + competing_locks: list[dict] = [] + try: + all_live = issue_lock_store.list_live_locks() + for l in all_live: + if l.get("issue_number") == issue_number: + wt = l.get("worktree_path") + if not wt or not issue_lock_store._same_realpath(wt, src): + competing_locks.append(l) + except Exception: + competing_locks = [] + + wf_active = False + wf_expired = True + try: + db, _ = _control_plane_db_or_error() + if db is not None: + active_leases_data = lease_lifecycle.list_active_leases( + db, + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + ) + leases_list = active_leases_data.get("leases") or [] + for l in leases_list: + if l.get("work_number") == issue_number and l.get("work_kind") == "issue": + fresh = l.get("freshness") or {} + if fresh.get("status") == "active": + wf_active = True + wf_expired = False + elif fresh.get("status") in ("expired", "stale_dead_process"): + wf_active = False + wf_expired = True + except Exception: + pass + assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery( existing_lock, issue_number=issue_number, @@ -4500,10 +4533,10 @@ def gitea_recover_dirty_orphaned_issue_worktree( observed_local_head=observed_local, observed_remote_head=observed_remote, observed_dirty_fingerprints=observed_fps, - competing_live_locks=[], + competing_live_locks=competing_locks, competing_live_sessions=[], - workflow_lease_active=False, - workflow_lease_expired=True, + workflow_lease_active=wf_active, + workflow_lease_expired=wf_expired, canonical_repo_root=canonical_root, worktree_registered=registered, current_pid=os.getpid(), diff --git a/issue_lock_provenance.py b/issue_lock_provenance.py index 87ee38f..dddd63d 100644 --- a/issue_lock_provenance.py +++ b/issue_lock_provenance.py @@ -16,11 +16,13 @@ ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock SOURCE_LOCK_ISSUE = "gitea_lock_issue" SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" SOURCE_OPERATOR_OVERRIDE = "operator_override" +SOURCE_RECOVER_DIRTY_ORPHANED = "gitea_recover_dirty_orphaned_issue_worktree" SANCTIONED_LOCK_SOURCES = frozenset({ SOURCE_LOCK_ISSUE, SOURCE_LOCK_ADOPTION, SOURCE_OPERATOR_OVERRIDE, + SOURCE_RECOVER_DIRTY_ORPHANED, }) _OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" diff --git a/issue_lock_store.py b/issue_lock_store.py index 0d973a2..ffdea43 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -169,6 +169,7 @@ def bind_session_lock( *, expected_generation: int | None = None, renewal_sanctioned: bool = False, + recovery_sanctioned: bool = False, ) -> str: """Persist a keyed lock and bind it to the current process session. @@ -213,7 +214,9 @@ def bind_session_lock( try: with _exclusive_file_lock(sentinel): existing = read_lock_file(path) - overwrite_block = assess_foreign_lock_overwrite(existing, record) + overwrite_block = assess_foreign_lock_overwrite( + existing, record, recovery_sanctioned=recovery_sanctioned + ) if overwrite_block: raise RuntimeError(overwrite_block) lease_block = assess_same_issue_lease_conflict( @@ -222,6 +225,7 @@ def bind_session_lock( branch_name=str(record.get("branch_name") or ""), worktree_path=str(record.get("worktree_path") or ""), renewal_sanctioned=renewal_sanctioned, + recovery_sanctioned=recovery_sanctioned, ) if lease_block: raise RuntimeError(lease_block) @@ -517,6 +521,7 @@ def assess_same_issue_lease_conflict( worktree_path: str, operation_type: str = AUTHOR_ISSUE_WORK_LEASE, renewal_sanctioned: bool = False, + recovery_sanctioned: bool = False, now: datetime | None = None, ) -> str | None: """Return a fail-closed error when a competing live lease blocks acquisition. @@ -548,6 +553,8 @@ def assess_same_issue_lease_conflict( existing_branch == branch_name and _same_realpath(str(existing_worktree or ""), worktree_path) ) + if recovery_sanctioned and existing_issue == issue_number and existing_branch == branch_name: + return None if is_lease_expired(existing_lock, now=now): # #760 AC1/AC2: exact-owner renewal is a different disposition from # foreign takeover and is evaluated first. Before this, both branches @@ -578,10 +585,26 @@ def assess_same_issue_lease_conflict( ) +def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]: + if not isinstance(lock, dict): + return {} + claimant = lock.get("claimant") + if not isinstance(claimant, dict): + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, dict) else None + if not isinstance(claimant, dict): + return {} + return { + "username": str(claimant.get("username") or ""), + "profile": str(claimant.get("profile") or ""), + } + + def assess_foreign_lock_overwrite( existing_lock: dict[str, Any] | None, incoming_lock: dict[str, Any], *, + recovery_sanctioned: bool = False, now: datetime | None = None, ) -> str | None: """Block writes that would clobber an unrelated live lease on the same key.""" @@ -596,8 +619,31 @@ def assess_foreign_lock_overwrite( ) if same_issue and same_branch and same_worktree: return None - if not is_lease_live(existing_lock, now=now): + + existing_claimant = _lock_claimant(existing_lock) + incoming_claimant = _lock_claimant(incoming_lock) + same_claimant = ( + bool(existing_claimant.get("username")) + and existing_claimant.get("username") == incoming_claimant.get("username") + and existing_claimant.get("profile") == incoming_claimant.get("profile") + ) + + if recovery_sanctioned and same_issue and same_branch and same_claimant: return None + + if not is_lease_live(existing_lock, now=now): + # #860 F8: A non-live or PID-less lock still blocks foreign overwrite + # unless same claimant or sanctioned reclaim is proven. + if not same_claimant and same_issue: + reclaim = assess_expired_lock_reclaim(existing_lock, now=now) + if not reclaim.get("reclaim_allowed"): + return ( + "Refusing foreign overwrite of non-live issue lock " + f"(issue #{existing_lock.get('issue_number')}, owner '{existing_claimant.get('username')}') " + "without sanctioned reclaim proof (fail closed)" + ) + return None + return ( "Refusing to overwrite a live foreign issue lock " f"(issue #{existing_lock.get('issue_number')}, " diff --git a/scripts/worktree-start b/scripts/worktree-start index a189164..a79e908 100755 --- a/scripts/worktree-start +++ b/scripts/worktree-start @@ -43,19 +43,21 @@ repo_root="$(cd "$script_dir/.." && pwd)" # Enforce issue-linked, traceable branch names (issue → branch → worktree → PR). if [[ "$allow_unlinked" -eq 0 ]]; then - locked_branch=$(python3 -c " + if [[ "$dry_run" -eq 0 ]] && [[ ! "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then + locked_branch=$(python3 -c " import sys sys.path.insert(0, '$repo_root') import issue_lock_store print(issue_lock_store.resolve_locked_branch_for_session('$branch')) ") - if [[ -z "$locked_branch" ]]; then - echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2 - exit 2 - fi - if [[ "$branch" != "$locked_branch" ]]; then - echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2 - exit 2 + if [[ -z "$locked_branch" ]]; then + echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2 + exit 2 + fi + if [[ "$branch" != "$locked_branch" ]]; then + echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2 + exit 2 + fi fi if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \ diff --git a/task_capability_map.py b/task_capability_map.py index b8ef555..d0d783b 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -486,6 +486,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { # merger lease (#763). _PREFLIGHT_TASK_TRANSITIONS = frozenset({ ("review_pr", "acquire_reviewer_pr_lease"), + ("work_issue", "lock_issue"), + ("work_issue", "recover_dirty_orphaned_issue_worktree"), + ("work_issue", "gitea_recover_dirty_orphaned_issue_worktree"), + ("work_issue", "commit_files"), + ("work_issue", "gitea_commit_files"), }) diff --git a/tests/test_dirty_orphan_worktree_recovery.py b/tests/test_dirty_orphan_worktree_recovery.py index b757837..4e58b4a 100644 --- a/tests/test_dirty_orphan_worktree_recovery.py +++ b/tests/test_dirty_orphan_worktree_recovery.py @@ -305,7 +305,8 @@ class CrashSafeRecovery(unittest.TestCase): local_head_contents={"c.py": b"local-base"}, remote_head_contents={"c.py": b"remote-changed"}, ) - self.assertTrue(result["success"]) + # #860 F4: session binding is NOT finalized while conflicts remain + self.assertFalse(result["success"]) self.assertEqual(result["outcome"], dorec.CONFLICTS_PRESENT) sidecar = os.path.join(self.recovery, "c.py.recovered-dirty") self.assertTrue(os.path.isfile(sidecar)) @@ -403,10 +404,8 @@ class JournalSymlinkRefusal(unittest.TestCase): fh.write("{}") link = os.path.join(tmp, "link.json") os.symlink(real, link) - # Point journal path helper via env key = "symlink-test" jdir = tmp - # Craft path that is a symlink by saving then replacing path = dorec._journal_path(key, journal_dir=jdir) with open(path, "w", encoding="utf-8") as fh: json.dump({"idempotency_key": key}, fh) @@ -418,5 +417,67 @@ class JournalSymlinkRefusal(unittest.TestCase): shutil.rmtree(tmp, ignore_errors=True) +class RealGitMultiWorktreeIntegration(unittest.TestCase): + def setUp(self): + import subprocess + self.tmp = tempfile.mkdtemp(prefix="git-integration-") + self.repo = os.path.join(self.tmp, "repo") + os.makedirs(self.repo, exist_ok=True) + subprocess.run(["git", "init"], cwd=self.repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.repo, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.repo, check=True) + with open(os.path.join(self.repo, "init.txt"), "w") as fh: + fh.write("init") + subprocess.run(["git", "add", "."], cwd=self.repo, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=self.repo, check=True) + branch = "fix/issue-999-test" + subprocess.run(["git", "branch", branch], cwd=self.repo, check=True) + self.branches = os.path.join(self.repo, "branches") + self.source = os.path.join(self.branches, "issue-999-test") + subprocess.run(["git", "worktree", "add", self.source, branch], cwd=self.repo, check=True) + self.dirty_path = os.path.join(self.source, "dirty.txt") + with open(self.dirty_path, "w") as fh: + fh.write("dirty-data") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_prepare_recovery_worktree_detached_no_exit_128(self): + import subprocess + head_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=self.repo, text=True).strip() + rec_wt = os.path.join(self.branches, "recovery-issue-999-test") + res = dorec.prepare_recovery_worktree( + canonical_repo_root=self.repo, + recovery_worktree_path=rec_wt, + branch_name="fix/issue-999-test", + remote_head=head_sha, + ) + self.assertTrue(res["success"], res.get("reasons")) + self.assertTrue(os.path.isdir(rec_wt)) + + def test_real_lock_rebind_recovery_sanctioned(self): + lock_dir = os.path.join(self.tmp, "locks") + rec_wt = os.path.join(self.branches, "recovery-issue-999-test") + os.makedirs(rec_wt, exist_ok=True) + record = { + "remote": "prgs", + "org": "Example-Org", + "repo": "Example-Repo", + "issue_number": 999, + "branch_name": "fix/issue-999-test", + "worktree_path": rec_wt, + "claimant": {"username": "author-user", "profile": "prgs-author"}, + } + record_src = dict(record) + record_src["worktree_path"] = self.source + issue_lock_store.bind_session_lock(record_src, lock_dir=lock_dir) + path = issue_lock_store.bind_session_lock( + record, + lock_dir=lock_dir, + recovery_sanctioned=True, + ) + self.assertTrue(os.path.isfile(path)) + + if __name__ == "__main__": unittest.main() From a3f8f67c9323ec7e78b5c7cc8ce8b051ab061e23 Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Thu, 23 Jul 2026 22:54:15 -0500 Subject: [PATCH 13/19] feat(author): add dirty-preserving same-claimant author-session rebind (Closes #864) Introduce an explicit, fail-closed recovery operation that rebinds a live author session to an already-registered dirty issue worktree when the durable lock still belongs to the same identity/profile and the recorded owner PID is provably dead. Ordinary locking remains clean-worktree-only; this path updates only stale lock/session provenance, preserves every dirty byte under fingerprint pins, and grants create-PR-sanctioned provenance without remote sync or recovery worktrees. Also stop treating dead-owner session pointers as live mutation workspace bindings so a stale dead-owner pointer cannot poison unrelated author work. --- dirty_same_claimant_session_rebind.py | 1232 +++++++++++++++++ gitea_mcp_server.py | 279 +++- issue_lock_provenance.py | 5 + task_capability_map.py | 11 + ...test_dirty_same_claimant_session_rebind.py | 845 +++++++++++ 5 files changed, 2371 insertions(+), 1 deletion(-) create mode 100644 dirty_same_claimant_session_rebind.py create mode 100644 tests/test_dirty_same_claimant_session_rebind.py diff --git a/dirty_same_claimant_session_rebind.py b/dirty_same_claimant_session_rebind.py new file mode 100644 index 0000000..1dda6b8 --- /dev/null +++ b/dirty_same_claimant_session_rebind.py @@ -0,0 +1,1232 @@ +"""Dirty-preserving same-claimant author-session rebind (#864). + +A registered issue worktree can be dirty while its durable lock owner PID is +provably dead. Ordinary ``gitea_lock_issue`` refuses dirty trees, and dead-session +recovery (#753) also requires cleanliness. This module is the *only* sanctioned +path that rebinds session/lock provenance onto the *same* worktree without +touching tracked or untracked content. + +This is SEPARATE from #860 dirty-orphan recovery (PID-less + remote sync). +This operation: + +* acts only on an already-registered dirty worktree +* updates only stale lock/session provenance (PID, session pointer, generation, + heartbeat) +* preserves every tracked/untracked byte +* does NOT sync remote, create recovery worktrees, clean, reset, or change heads +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +from datetime import datetime, timezone +from typing import Any, Mapping, Sequence + +from author_mutation_worktree import is_path_under_branches +from issue_lock_provenance import ( + SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + build_sanctioned_lock_provenance, +) +from issue_lock_store import ( + AUTHOR_ISSUE_WORK_LEASE, + bind_session_lock, + is_process_alive, + lock_file_path, + lock_generation, + read_lock_file, +) +from reviewer_worktree import parse_dirty_tracked_files + +# Outcomes +REBIND_SANCTIONED = "REBIND_SANCTIONED" +REFUSED = "REFUSED" +NO_CANDIDATE = "NO_CANDIDATE" + +# Provenance / tool identity +SOURCE_TOOL = SOURCE_DIRTY_SAME_CLAIMANT_REBIND +SOURCE = SOURCE_DIRTY_SAME_CLAIMANT_REBIND + +# Journal phases (crash-safe apply) +JOURNAL_PHASE_ASSESSED = "assessed" +JOURNAL_PHASE_PRE_BIND = "pre_bind" +JOURNAL_PHASE_BOUND = "bound" +JOURNAL_PHASE_COMPLETE = "complete" +JOURNAL_PHASE_ALREADY_REBOUND = "already_rebound" + +REQUIRED_LOCK_FIELDS = ( + "issue_number", + "branch_name", + "worktree_path", + "remote", + "org", + "repo", +) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _same_realpath(left: str | None, right: str | None) -> bool: + if not left or not right: + return False + try: + return os.path.realpath(left) == os.path.realpath(right) + except OSError: + return left == right + + +def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]: + claimant = lock.get("claimant") + if not isinstance(claimant, Mapping): + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, Mapping) else None + return dict(claimant) if isinstance(claimant, Mapping) else {} + + +def _recorded_pid(lock: Mapping[str, Any]) -> Any: + pid = lock.get("session_pid") + if pid is None: + pid = lock.get("pid") + return pid + + +def content_fingerprint(path: str) -> str: + """Return sha256 hex digest of file bytes at *path*. + + Missing or unreadable files raise ``OSError`` / ``FileNotFoundError`` so + callers fail closed rather than inventing an empty hash. + """ + digest = hashlib.sha256() + with open(path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def parse_dirty_paths(porcelain: str) -> list[str]: + """Tracked + untracked paths from ``git status --porcelain -uall`` output.""" + paths: list[str] = [] + seen: set[str] = set() + for line in (porcelain or "").splitlines(): + if not line or len(line) < 4: + continue + if line.startswith("??"): + path = line[3:].strip() + else: + path = line[3:].strip() + if " -> " in path: + path = path.split(" -> ", 1)[1].strip() + if not path or path in seen: + continue + seen.add(path) + paths.append(path) + return paths + + +def collect_dirty_inventory(worktree_path: str) -> dict[str, Any]: + """Observe dirty tracked + untracked paths and content fingerprints. + + Uses ``git status --porcelain -uall`` so every untracked file is listed + individually (not collapsed into a directory). + """ + path = (worktree_path or "").strip() + if not path: + return { + "worktree_path": path, + "porcelain_status": "", + "dirty_paths": [], + "fingerprints": {}, + "ok": False, + "reasons": ["worktree path is empty"], + } + + status_res = subprocess.run( + ["git", "-C", path, "status", "--porcelain", "-uall"], + capture_output=True, + text=True, + check=False, + ) + if status_res.returncode != 0: + err = (status_res.stderr or status_res.stdout or "").strip() + return { + "worktree_path": path, + "porcelain_status": "", + "dirty_paths": [], + "fingerprints": {}, + "ok": False, + "reasons": [f"git status failed in '{path}': {err or 'unknown error'}"], + } + + porcelain = status_res.stdout or "" + dirty_paths = parse_dirty_paths(porcelain) + fingerprints: dict[str, str] = {} + reasons: list[str] = [] + for rel in dirty_paths: + abs_path = os.path.join(path, rel) + if os.path.isdir(abs_path) and not os.path.islink(abs_path): + # Directories appear only if git reported them; fingerprinting a + # directory is not defined — fail closed. + reasons.append(f"dirty path '{rel}' is a directory; cannot fingerprint") + continue + try: + fingerprints[rel] = content_fingerprint(abs_path) + except OSError as exc: + reasons.append(f"could not fingerprint '{rel}': {exc}") + + return { + "worktree_path": os.path.realpath(path), + "porcelain_status": porcelain, + "dirty_paths": dirty_paths, + "fingerprints": fingerprints, + "ok": not reasons, + "reasons": reasons, + "tracked_dirty": parse_dirty_tracked_files(porcelain), + } + + +def journal_path(lock_dir: str, issue_number: int) -> str: + root = (lock_dir or "").strip() + return os.path.join(root, f".rebind-journal-{int(issue_number)}.json") + + +def _atomic_write_json(path: str, data: dict[str, Any]) -> None: + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + payload = json.dumps(data, indent=2, sort_keys=True) + "\n" + fd, temp_path = tempfile.mkstemp(prefix=".rebind-j-", suffix=".json", dir=parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def _read_json(path: str) -> dict[str, Any] | None: + if not path or not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _malformed_lock_reasons(lock: Mapping[str, Any]) -> list[str]: + missing: list[str] = [] + for field in REQUIRED_LOCK_FIELDS: + if not _text(lock.get(field)): + missing.append(field) + pid = _recorded_pid(lock) + if pid is None or _text(pid) == "": + missing.append("session_pid/pid") + else: + try: + if int(pid) <= 0: + missing.append("session_pid/pid") + except (TypeError, ValueError): + missing.append("session_pid/pid") + return missing + + +def _canonical_under_branches(worktree_path: str, repo_root: str | None) -> tuple[bool, list[str]]: + """Prove worktree is a realpath under ``/branches/`` with no symlink escape.""" + reasons: list[str] = [] + path = (worktree_path or "").strip() + if not path: + return False, ["worktree path is empty"] + try: + real = os.path.realpath(path) + except OSError as exc: + return False, [f"worktree path could not be realpath-resolved: {exc}"] + if not os.path.isdir(real): + reasons.append(f"worktree path '{path}' is not an existing directory") + + root = (repo_root or "").strip() + if root: + try: + root_real = os.path.realpath(root) + except OSError as exc: + return False, [f"repo root could not be realpath-resolved: {exc}"] + if not is_path_under_branches(real, root_real): + reasons.append( + f"worktree '{real}' is not under branches/ of repo root '{root_real}' " + "(unregistered/noncanonical worktree; fail closed)" + ) + # Symlink escape: the declared path must not resolve outside branches/. + declared_abs = os.path.abspath(path) + if os.path.islink(path) or declared_abs != real: + if not is_path_under_branches(real, root_real): + reasons.append( + f"worktree path '{path}' escapes branches/ via symlink/realpath " + f"(resolves to '{real}')" + ) + else: + # Without an explicit repo root, still require a /branches/ segment. + if not is_path_under_branches(real, None): + reasons.append( + f"worktree '{real}' is not under a branches/ directory " + "(unregistered/noncanonical worktree; fail closed)" + ) + return not reasons, reasons + + +def assess_dirty_same_claimant_session_rebind( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + claimant_identity: str | None, + claimant_profile: str | None, + old_pid: int | None, + expected_local_head: str | None, + expected_remote_head: str | None, + expected_dirty_paths: Sequence[str] | None, + expected_fingerprints: Mapping[str, str] | None, + existing_lock: Mapping[str, Any] | None, + current_identity: str | None, + current_profile: str | None, + role_kind: str | None, + current_pid: int | None, + current_branch: str | None, + local_head: str | None, + remote_head: str | None, + porcelain_status: str | None = None, + dirty_inventory: Mapping[str, Any] | None = None, + competing_live_locks: Sequence[Mapping[str, Any]] | None = None, + competing_sessions: Sequence[Mapping[str, Any]] | None = None, + workflow_lease_active: bool = False, + authorize_reconciler_execute: bool = False, + permission_allowed: bool = False, + repo_root: str | None = None, +) -> dict[str, Any]: + """Pure assessment: may this dirty same-claimant lock be session-rebound? + + Every pin must agree. ``permission_allowed=True`` alone is never ownership + proof. Fail closed on live old PID, foreign identity/profile, pin mismatch, + unregistered/noncanonical worktree, head movement, dirty path/fingerprint + disagreement, competing ownership, malformed lock, empty PID, wrong role. + """ + reasons: list[str] = [] + evidence: dict[str, Any] = { + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": worktree_path, + "remote": remote, + "org": org, + "repo": repo, + "old_pid": old_pid, + "current_pid": current_pid if current_pid is not None else os.getpid(), + "role_kind": _text(role_kind).lower() or None, + "permission_allowed": bool(permission_allowed), + } + + if not existing_lock: + return _assessment_result( + NO_CANDIDATE, + False, + ["no existing durable lock for this issue; not a rebind candidate"], + evidence, + ) + + lock = dict(existing_lock) + if lock.get("issue_number") != issue_number: + return _assessment_result( + NO_CANDIDATE, + False, + [ + f"existing lock targets issue #{lock.get('issue_number')}, " + f"not #{issue_number}; not a rebind candidate" + ], + evidence, + ) + + missing = _malformed_lock_reasons(lock) + if missing: + return _assessment_result( + REFUSED, + False, + [ + "durable lock record is incomplete and cannot prove ownership " + f"(missing/unusable: {', '.join(missing)})" + ], + evidence, + ) + + recorded_pid = _recorded_pid(lock) + evidence["recorded_pid"] = recorded_pid + evidence["lock_generation"] = lock_generation(lock) + + # ── Role gate ─────────────────────────────────────────────────────────── + role = _text(role_kind).lower() + if role in {"reviewer", "merger"}: + reasons.append( + f"role '{role}' cannot rebind dirty same-claimant author sessions " + "(fail closed)" + ) + elif role == "reconciler": + if not authorize_reconciler_execute: + reasons.append( + "reconciler role requires authorize_reconciler_execute=True " + "to execute dirty same-claimant rebind (fail closed)" + ) + elif role == "author": + pass + elif role: + reasons.append(f"role '{role}' is not authorized for dirty same-claimant rebind") + else: + reasons.append("role_kind is unknown; dirty same-claimant rebind refused") + + # permission_allowed is explicitly NOT ownership proof + evidence["note_permission_not_ownership"] = ( + "permission_allowed is not treated as ownership proof" + ) + + # ── Repository / issue / branch / worktree pins ───────────────────────── + for field, expected in (("remote", remote), ("org", org), ("repo", repo)): + actual = _text(lock.get(field)) + if actual != _text(expected): + reasons.append( + f"lock {field} '{actual}' does not match requested '{_text(expected)}'" + ) + + locked_branch = _text(lock.get("branch_name")) + if locked_branch != _text(branch_name): + reasons.append( + f"lock branch '{locked_branch}' does not match requested " + f"'{_text(branch_name)}'" + ) + + checked_out = _text(current_branch) + if not checked_out: + reasons.append( + "worktree is not on a named branch (detached HEAD); locked-branch " + "occupancy could not be proven" + ) + elif checked_out != locked_branch: + reasons.append( + f"worktree is on branch '{checked_out}', not the locked branch " + f"'{locked_branch}'" + ) + + locked_worktree = _text(lock.get("worktree_path")) + if not _same_realpath(locked_worktree, worktree_path): + reasons.append( + f"lock worktree '{locked_worktree}' does not match declared " + f"'{_text(worktree_path)}'" + ) + evidence["locked_worktree_path"] = locked_worktree + + under_ok, under_reasons = _canonical_under_branches(worktree_path, repo_root) + if not under_ok: + reasons.extend(under_reasons) + + # ── old_pid pin + liveness ────────────────────────────────────────────── + if old_pid is None or _text(old_pid) == "": + reasons.append("old_pid pin is empty; rebind refused (fail closed)") + else: + try: + old_pid_i = int(old_pid) + except (TypeError, ValueError): + reasons.append(f"old_pid '{old_pid}' is not a valid PID") + old_pid_i = None + if old_pid_i is not None: + if old_pid_i <= 0: + reasons.append("old_pid must be a positive integer (fail closed)") + try: + recorded_i = int(recorded_pid) + except (TypeError, ValueError): + recorded_i = None + if recorded_i is None or recorded_i != old_pid_i: + reasons.append( + f"old_pid {old_pid_i} does not match lock session_pid/pid " + f"{recorded_pid}" + ) + if is_process_alive(old_pid_i): + reasons.append( + f"old_pid {old_pid_i} is still alive; dirty same-claimant " + "rebind requires a provably dead owner (fail closed)" + ) + evidence["old_pid_alive"] = is_process_alive(old_pid_i) + if current_pid is not None: + try: + if int(current_pid) == old_pid_i: + reasons.append( + "old_pid is the current session PID; nothing to rebind" + ) + except (TypeError, ValueError): + pass + + # ── Claimant identity / profile ───────────────────────────────────────── + lock_claimant = _lock_claimant(lock) + locked_identity = _text(lock_claimant.get("username")) + locked_profile = _text(lock_claimant.get("profile")) + pin_identity = _text(claimant_identity) + pin_profile = _text(claimant_profile) + active_identity = _text(current_identity) + active_profile = _text(current_profile) + evidence["locked_identity"] = locked_identity or None + evidence["locked_profile"] = locked_profile or None + + if not locked_identity or not locked_profile: + reasons.append( + "durable lock does not record a claimant identity/profile; " + "ownership could not be proven" + ) + if not pin_identity or not pin_profile: + reasons.append( + "claimant_identity/claimant_profile pins are required (fail closed)" + ) + if locked_identity and pin_identity and locked_identity != pin_identity: + reasons.append( + f"claimant_identity pin '{pin_identity}' does not match lock " + f"claimant '{locked_identity}'" + ) + if locked_profile and pin_profile and locked_profile != pin_profile: + reasons.append( + f"claimant_profile pin '{pin_profile}' does not match lock profile " + f"'{locked_profile}'" + ) + + # Author path: active session must be the same claimant. Reconciler execute + # may rebind for the recorded claimant when explicitly authorized. + if role == "author": + if not active_identity or not active_profile: + reasons.append( + "active session identity/profile is unknown; author ownership " + "could not be proven" + ) + if locked_identity and active_identity and locked_identity != active_identity: + reasons.append( + f"lock claimant '{locked_identity}' does not match active " + f"identity '{active_identity}' (foreign claimant refused)" + ) + if locked_profile and active_profile and locked_profile != active_profile: + reasons.append( + f"lock profile '{locked_profile}' does not match active profile " + f"'{active_profile}' (profile mismatch refused)" + ) + if pin_identity and active_identity and pin_identity != active_identity: + reasons.append( + f"claimant_identity pin '{pin_identity}' does not match active " + f"identity '{active_identity}'" + ) + if pin_profile and active_profile and pin_profile != active_profile: + reasons.append( + f"claimant_profile pin '{pin_profile}' does not match active " + f"profile '{active_profile}'" + ) + + # ── Heads (must match pins and each other for this rebind class) ──────── + obs_local = _text(local_head) + obs_remote = _text(remote_head) + pin_local = _text(expected_local_head) + pin_remote = _text(expected_remote_head) + evidence["local_head"] = obs_local or None + evidence["remote_head"] = obs_remote or None + evidence["expected_local_head"] = pin_local or None + evidence["expected_remote_head"] = pin_remote or None + + if not pin_local or not pin_remote: + reasons.append( + "expected_local_head and expected_remote_head pins are required " + "(fail closed)" + ) + if not obs_local: + reasons.append("local head SHA could not be determined") + if not obs_remote: + reasons.append("remote head SHA could not be determined") + if pin_local and obs_local and pin_local != obs_local: + reasons.append( + f"local head moved or mismatched pin: observed {obs_local}, " + f"expected {pin_local}" + ) + if pin_remote and obs_remote and pin_remote != obs_remote: + reasons.append( + f"remote head moved or mismatched pin: observed {obs_remote}, " + f"expected {pin_remote}" + ) + if obs_local and obs_remote and obs_local != obs_remote: + # Dirty rebind does not allow unpublished head movement; heads must agree. + reasons.append( + f"local head {obs_local} does not match remote head {obs_remote}; " + "dirty same-claimant rebind requires matching heads (fail closed)" + ) + + # ── Dirty inventory + fingerprint pins ────────────────────────────────── + inv: dict[str, Any] + if isinstance(dirty_inventory, Mapping) and dirty_inventory.get("dirty_paths") is not None: + inv = dict(dirty_inventory) + if not inv.get("fingerprints") and porcelain_status is not None: + # Allow fingerprints-only refresh via recompute if needed. + pass + elif porcelain_status is not None: + # Porcelain alone proves path set, not bytes. Fingerprints must come from + # dirty_inventory (or apply()'s collect_dirty_inventory) — never from the + # caller's expected_fingerprints pin (that would make the pin tautological). + dirty_paths_obs = parse_dirty_paths(porcelain_status) + inv = { + "porcelain_status": porcelain_status, + "dirty_paths": dirty_paths_obs, + "fingerprints": {}, + "ok": True, + "reasons": [], + } + else: + reasons.append( + "neither dirty_inventory nor porcelain_status was provided; " + "dirty state could not be proven" + ) + inv = {"dirty_paths": [], "fingerprints": {}, "ok": False} + + if inv.get("ok") is False and inv.get("reasons"): + reasons.extend(list(inv.get("reasons") or [])) + + observed_paths = sorted({_text(p) for p in (inv.get("dirty_paths") or []) if _text(p)}) + pin_paths = sorted({_text(p) for p in (expected_dirty_paths or []) if _text(p)}) + evidence["observed_dirty_paths"] = observed_paths + evidence["expected_dirty_paths"] = pin_paths + + if not pin_paths: + reasons.append( + "expected_dirty_paths pin is empty; dirty same-claimant rebind " + "requires a non-empty dirty inventory pin (fail closed)" + ) + if set(observed_paths) != set(pin_paths): + extra = sorted(set(observed_paths) - set(pin_paths)) + missing_p = sorted(set(pin_paths) - set(observed_paths)) + if extra: + reasons.append( + f"dirty path set disagreement: unexpected paths {extra}" + ) + if missing_p: + reasons.append( + f"dirty path set disagreement: missing expected paths {missing_p}" + ) + + obs_fps = { + _text(k): _text(v) + for k, v in dict(inv.get("fingerprints") or {}).items() + if _text(k) + } + pin_fps = { + _text(k): _text(v) + for k, v in dict(expected_fingerprints or {}).items() + if _text(k) + } + evidence["observed_fingerprints"] = obs_fps + evidence["expected_fingerprints"] = pin_fps + + if not pin_fps: + reasons.append( + "expected_fingerprints pin is empty; byte-level pins are required " + "(fail closed)" + ) + else: + for rel, expected_hash in pin_fps.items(): + if rel not in set(pin_paths): + reasons.append( + f"expected_fingerprints contains '{rel}' which is not in " + "expected_dirty_paths" + ) + actual_hash = obs_fps.get(rel) + if not actual_hash: + reasons.append( + f"fingerprint missing for dirty path '{rel}'" + ) + elif actual_hash != expected_hash: + reasons.append( + f"fingerprint disagreement for '{rel}': observed " + f"{actual_hash}, expected {expected_hash}" + ) + for rel in obs_fps: + if rel in set(pin_paths) and rel not in pin_fps: + reasons.append( + f"expected_fingerprints missing pin for observed dirty path '{rel}'" + ) + + # ── Competing ownership ───────────────────────────────────────────────── + competing: list[dict[str, Any]] = [] + for entry in competing_live_locks or (): + if not isinstance(entry, Mapping): + continue + same_issue = entry.get("issue_number") == issue_number + same_branch = _text(entry.get("branch_name")) == locked_branch + if not (same_issue or same_branch): + continue + if ( + same_issue + and same_branch + and _same_realpath(_text(entry.get("worktree_path")), worktree_path) + ): + # The lock we are rebinding is not competition with itself, but a + # *live* competing owner on the same worktree is still a problem. + entry_pid = entry.get("pid") or entry.get("session_pid") + try: + entry_pid_i = int(entry_pid) if entry_pid is not None else None + except (TypeError, ValueError): + entry_pid_i = None + if entry_pid_i is not None and is_process_alive(entry_pid_i): + if old_pid is None or entry_pid_i != int(old_pid): + competing.append( + { + "issue_number": entry.get("issue_number"), + "branch_name": entry.get("branch_name"), + "worktree_path": entry.get("worktree_path"), + "pid": entry_pid_i, + } + ) + continue + competing.append( + { + "issue_number": entry.get("issue_number"), + "branch_name": entry.get("branch_name"), + "worktree_path": entry.get("worktree_path"), + "pid": entry.get("pid") or entry.get("session_pid"), + } + ) + if competing: + described = ", ".join( + f"issue #{c['issue_number']} branch '{c['branch_name']}' pid={c.get('pid')}" + for c in competing + ) + reasons.append(f"competing live lock exists ({described})") + evidence["competing_live_locks"] = competing + + competing_sess: list[dict[str, Any]] = [] + for entry in competing_sessions or (): + if not isinstance(entry, Mapping): + continue + sess_pid = entry.get("pid") or entry.get("session_pid") + try: + sess_pid_i = int(sess_pid) if sess_pid is not None else None + except (TypeError, ValueError): + sess_pid_i = None + if sess_pid_i is None: + continue + if current_pid is not None and sess_pid_i == int(current_pid): + continue + if old_pid is not None: + try: + if sess_pid_i == int(old_pid) and not is_process_alive(sess_pid_i): + continue + except (TypeError, ValueError): + pass + if is_process_alive(sess_pid_i) or entry.get("live") is True: + competing_sess.append( + { + "pid": sess_pid_i, + "lock_file_path": entry.get("lock_file_path"), + } + ) + if competing_sess: + reasons.append( + "competing live session pointer(s) claim this lock: " + + ", ".join(str(s["pid"]) for s in competing_sess) + ) + evidence["competing_sessions"] = competing_sess + + if workflow_lease_active: + reasons.append( + "workflow lease is active for this scope; dirty same-claimant " + "rebind refused (fail closed)" + ) + evidence["workflow_lease_active"] = bool(workflow_lease_active) + + if reasons: + return _assessment_result(REFUSED, False, reasons, evidence) + + proof = [ + f"registered dirty worktree for issue #{issue_number} on branch " + f"'{locked_branch}' matches claimant '{locked_identity}' / profile " + f"'{locked_profile}'; old_pid {recorded_pid} is dead; heads " + f"{obs_local} match; {len(pin_paths)} dirty paths fingerprint-pinned; " + "provenance-only rebind sanctioned" + ] + return _assessment_result(REBIND_SANCTIONED, True, proof, evidence) + + +def _assessment_result( + outcome: str, + sanctioned: bool, + reasons: list[str], + evidence: dict[str, Any], +) -> dict[str, Any]: + return { + "outcome": outcome, + "rebind_sanctioned": sanctioned, + "is_candidate": outcome != NO_CANDIDATE, + "reasons": reasons, + "evidence": evidence, + "expected_generation": evidence.get("lock_generation"), + } + + +def _already_rebound( + *, + existing_lock: Mapping[str, Any], + current_pid: int, + worktree_path: str, + expected_fingerprints: Mapping[str, str], + worktree_for_fps: str, +) -> tuple[bool, list[str]]: + """Return (True, notes) when lock is already rebound to this session.""" + notes: list[str] = [] + pid = _recorded_pid(existing_lock) + try: + pid_i = int(pid) if pid is not None else None + except (TypeError, ValueError): + return False, [] + if pid_i != int(current_pid): + return False, [] + if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path): + return False, [] + # Fingerprints must still match pins (byte preservation). + for rel, expected in (expected_fingerprints or {}).items(): + abs_path = os.path.join(worktree_for_fps, rel) + try: + actual = content_fingerprint(abs_path) + except OSError as exc: + notes.append(f"could not re-fingerprint '{rel}' for already_rebound: {exc}") + return False, notes + if actual != _text(expected): + notes.append( + f"fingerprint drift on already-rebound check for '{rel}'" + ) + return False, notes + gen = lock_generation(existing_lock) + if gen < 1: + # A never-written generation is suspicious for a completed rebind, but + # a same-pid lock with matching fingerprints is still "ours". + notes.append("lock generation is 0; treating same-pid match as rebound") + return True, notes or ["lock already bound to current session PID"] + + +def apply_dirty_same_claimant_session_rebind( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + claimant_identity: str | None, + claimant_profile: str | None, + old_pid: int | None, + expected_local_head: str | None, + expected_remote_head: str | None, + expected_dirty_paths: Sequence[str] | None, + expected_fingerprints: Mapping[str, str] | None, + existing_lock: Mapping[str, Any] | None, + current_identity: str | None, + current_profile: str | None, + role_kind: str | None, + current_pid: int | None = None, + current_branch: str | None, + local_head: str | None, + remote_head: str | None, + porcelain_status: str | None = None, + dirty_inventory: Mapping[str, Any] | None = None, + competing_live_locks: Sequence[Mapping[str, Any]] | None = None, + competing_sessions: Sequence[Mapping[str, Any]] | None = None, + workflow_lease_active: bool = False, + authorize_reconciler_execute: bool = False, + permission_allowed: bool = False, + repo_root: str | None = None, + dry_run: bool = False, + lock_dir: str | None = None, +) -> dict[str, Any]: + """Assess and (unless dry_run) apply a dirty same-claimant session rebind.""" + pid_now = int(current_pid) if current_pid is not None else os.getpid() + wt = os.path.realpath((worktree_path or "").strip()) if worktree_path else "" + + # Prefer a live inventory when applying so fingerprints are re-observed. + inv = dict(dirty_inventory) if isinstance(dirty_inventory, Mapping) else None + if inv is None and wt: + inv = collect_dirty_inventory(wt) + + assessment = assess_dirty_same_claimant_session_rebind( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + branch_name=branch_name, + worktree_path=worktree_path, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + old_pid=old_pid, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_paths=expected_dirty_paths, + expected_fingerprints=expected_fingerprints, + existing_lock=existing_lock, + current_identity=current_identity, + current_profile=current_profile, + role_kind=role_kind, + current_pid=pid_now, + current_branch=current_branch, + local_head=local_head, + remote_head=remote_head, + porcelain_status=porcelain_status + if porcelain_status is not None + else (inv or {}).get("porcelain_status"), + dirty_inventory=inv, + competing_live_locks=competing_live_locks, + competing_sessions=competing_sessions, + workflow_lease_active=workflow_lease_active, + authorize_reconciler_execute=authorize_reconciler_execute, + permission_allowed=permission_allowed, + repo_root=repo_root, + ) + + base_result: dict[str, Any] = { + "success": False, + "dry_run": bool(dry_run), + "outcome": assessment["outcome"], + "rebind_sanctioned": assessment["rebind_sanctioned"], + "reasons": list(assessment.get("reasons") or []), + "evidence": assessment.get("evidence") or {}, + "old_pid": old_pid, + "new_pid": pid_now, + "already_rebound": False, + "dirty_paths": list(expected_dirty_paths or []), + "fingerprints": dict(expected_fingerprints or {}), + "local_head": local_head, + "remote_head": remote_head, + } + + # Retry-safe: if already rebound to this session, succeed even when assess + # refuses because old_pid no longer matches the (updated) lock. + if ( + isinstance(existing_lock, Mapping) + and expected_fingerprints + and wt + ): + done, notes = _already_rebound( + existing_lock=existing_lock, + current_pid=pid_now, + worktree_path=worktree_path, + expected_fingerprints=expected_fingerprints, + worktree_for_fps=wt, + ) + if done: + lock_path = _text(existing_lock.get("lock_file_path")) or lock_file_path( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + lock_dir=lock_dir, + ) + session_ptr = os.path.join( + (lock_dir or os.path.dirname(lock_path) or "."), + f"session-{pid_now}.json", + ) + return { + **base_result, + "success": True, + "outcome": REBIND_SANCTIONED, + "rebind_sanctioned": True, + "already_rebound": True, + "reasons": notes, + "lock_path": lock_path, + "session_pointer": session_ptr, + "generation_before": lock_generation(existing_lock), + "generation_after": lock_generation(existing_lock), + "journal_phase": JOURNAL_PHASE_ALREADY_REBOUND, + } + + if not assessment["rebind_sanctioned"]: + return base_result + + if dry_run: + return { + **base_result, + "success": True, + "message": "dry_run: rebind sanctioned; no lock/session writes performed", + "generation_before": assessment.get("expected_generation"), + "generation_after": assessment.get("expected_generation"), + } + + lock = dict(existing_lock or {}) + gen_before = lock_generation(lock) + root = (lock_dir or "").strip() or None + jpath = journal_path( + root or os.path.dirname( + _text(lock.get("lock_file_path")) + or lock_file_path( + remote=remote, org=org, repo=repo, issue_number=issue_number + ) + ), + issue_number, + ) + + journal = { + "phase": JOURNAL_PHASE_ASSESSED, + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": wt, + "old_pid": old_pid, + "new_pid": pid_now, + "expected_generation": gen_before, + "expected_fingerprints": dict(expected_fingerprints or {}), + "expected_dirty_paths": list(expected_dirty_paths or []), + "local_head": local_head, + "remote_head": remote_head, + "started_at": _utc_now_iso(), + "source": SOURCE, + } + _atomic_write_json(jpath, journal) + + # Immediate pre-bind fingerprint re-verification. + pre_fps: dict[str, str] = {} + for rel in expected_dirty_paths or []: + abs_path = os.path.join(wt, rel) + try: + pre_fps[rel] = content_fingerprint(abs_path) + except OSError as exc: + return { + **base_result, + "success": False, + "reasons": [f"pre-bind fingerprint failed for '{rel}': {exc}"], + "journal_path": jpath, + } + for rel, expected in (expected_fingerprints or {}).items(): + if pre_fps.get(rel) != _text(expected): + return { + **base_result, + "success": False, + "reasons": [ + f"pre-bind fingerprint drift for '{rel}': " + f"observed {pre_fps.get(rel)}, expected {expected}" + ], + "journal_path": jpath, + } + + journal["phase"] = JOURNAL_PHASE_PRE_BIND + journal["pre_bind_fingerprints"] = pre_fps + _atomic_write_json(jpath, journal) + + now = _utc_now_iso() + new_lock = dict(lock) + new_lock["session_pid"] = pid_now + new_lock["pid"] = pid_now + new_lock["last_heartbeat_at"] = now + new_lock["remote"] = remote + new_lock["org"] = org + new_lock["repo"] = repo + new_lock["issue_number"] = issue_number + new_lock["branch_name"] = branch_name + new_lock["worktree_path"] = _text(lock.get("worktree_path")) or wt + + # Preserve work_lease (including expires_at); refresh heartbeat only. + lease = new_lock.get("work_lease") + if isinstance(lease, dict): + lease = dict(lease) + lease["last_heartbeat_at"] = now + if not lease.get("operation_type"): + lease["operation_type"] = AUTHOR_ISSUE_WORK_LEASE + new_lock["work_lease"] = lease + + claimant = _lock_claimant(lock) + new_lock["lock_provenance"] = build_sanctioned_lock_provenance( + tool=SOURCE_TOOL, + source=SOURCE, + claimant=claimant or { + "username": claimant_identity, + "profile": claimant_profile, + }, + ) + new_lock["rebind_record"] = { + "source": SOURCE, + "old_pid": old_pid, + "new_pid": pid_now, + "rebound_at": now, + "local_head": local_head, + "remote_head": remote_head, + "dirty_path_count": len(list(expected_dirty_paths or [])), + "generation_before": gen_before, + "evidence": { + "fingerprints": dict(expected_fingerprints or {}), + "dirty_paths": list(expected_dirty_paths or []), + }, + } + + try: + lock_path = bind_session_lock( + new_lock, + lock_dir=root, + expected_generation=gen_before, + ) + except Exception as exc: + journal["phase"] = "bind_failed" + journal["error"] = str(exc) + _atomic_write_json(jpath, journal) + return { + **base_result, + "success": False, + "reasons": [f"bind_session_lock failed: {exc}"], + "journal_path": jpath, + "generation_before": gen_before, + } + + journal["phase"] = JOURNAL_PHASE_BOUND + journal["lock_path"] = lock_path + _atomic_write_json(jpath, journal) + + # Post-bind fingerprint verification — every byte unchanged. + post_fps: dict[str, str] = {} + for rel in expected_dirty_paths or []: + abs_path = os.path.join(wt, rel) + try: + post_fps[rel] = content_fingerprint(abs_path) + except OSError as exc: + return { + **base_result, + "success": False, + "reasons": [ + f"post-bind fingerprint failed for '{rel}': {exc}; " + "lock may be rebound but content verification failed" + ], + "lock_path": lock_path, + "journal_path": jpath, + "generation_before": gen_before, + } + for rel, expected in (expected_fingerprints or {}).items(): + if post_fps.get(rel) != _text(expected): + return { + **base_result, + "success": False, + "reasons": [ + f"post-bind fingerprint drift for '{rel}': " + f"observed {post_fps.get(rel)}, expected {expected}" + ], + "lock_path": lock_path, + "journal_path": jpath, + "generation_before": gen_before, + "fingerprints_after": post_fps, + } + + # Remove stale session pointer for old_pid when it points at this lock. + removed_old_pointer = False + if old_pid is not None and root: + old_ptr = os.path.join(root, f"session-{int(old_pid)}.json") + if os.path.exists(old_ptr): + ptr = _read_json(old_ptr) or {} + ptr_lock = _text(ptr.get("lock_file_path")) + if not ptr_lock or os.path.realpath(ptr_lock) == os.path.realpath(lock_path): + try: + os.remove(old_ptr) + removed_old_pointer = True + except OSError: + pass + + bound = read_lock_file(lock_path) or new_lock + gen_after = lock_generation(bound) + session_ptr = os.path.join( + root or os.path.dirname(lock_path), + f"session-{pid_now}.json", + ) + + journal["phase"] = JOURNAL_PHASE_COMPLETE + journal["completed_at"] = _utc_now_iso() + journal["generation_after"] = gen_after + journal["removed_old_session_pointer"] = removed_old_pointer + journal["post_bind_fingerprints"] = post_fps + _atomic_write_json(jpath, journal) + + return { + **base_result, + "success": True, + "message": ( + f"Rebound dirty same-claimant author session for issue #{issue_number} " + f"from dead pid {old_pid} to pid {pid_now}; dirty bytes preserved" + ), + "lock_path": lock_path, + "session_pointer": session_ptr, + "generation_before": gen_before, + "generation_after": gen_after, + "fingerprints": post_fps, + "fingerprints_before": pre_fps, + "removed_old_session_pointer": removed_old_pointer, + "journal_path": jpath, + "journal_phase": JOURNAL_PHASE_COMPLETE, + "rebind_record": bound.get("rebind_record") or new_lock.get("rebind_record"), + "lock_provenance": bound.get("lock_provenance"), + } + + +def build_issue_860_regression_fixture_spec() -> dict[str, Any]: + """Data-only fixture describing the #860 class scenario (no real mutation). + + Claimant jcwalker3 / prgs-author, dead PID, no live session pointer, seven + dirty paths with fingerprint pins, matching local/remote heads. + """ + dirty_paths = [ + "dirty_same_claimant_session_rebind.py", + "issue_lock_provenance.py", + "task_capability_map.py", + "gitea_mcp_server.py", + "tests/test_dirty_same_claimant_session_rebind.py", + "docs/runbook-dirty-rebind.md", + "scratch/notes-untracked.txt", + ] + # Stable placeholder digests — tests replace with real fingerprints when + # constructing on-disk fixtures. These exist so the spec is self-describing. + fingerprints = { + path: hashlib.sha256(f"issue-860-fixture:{path}".encode()).hexdigest() + for path in dirty_paths + } + head = "a" * 40 + dead = 424860 + return { + "issue_class": "issue-860-dirty-orphan-class-fixture", + "description": ( + "Registered dirty worktree, same claimant, dead owner PID, no live " + "session pointer, seven fingerprint-pinned dirty paths, matching heads. " + "Data only — does not mutate any real worktree." + ), + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "issue_number": 860, + "branch_name": "fix/issue-860-dirty-orphan-recovery", + "worktree_path": "/scratch/branches/fix-issue-860-dirty-orphan-recovery", + "claimant_identity": "jcwalker3", + "claimant_profile": "prgs-author", + "old_pid": dead, + "old_pid_alive": False, + "live_session_pointer": None, + "expected_local_head": head, + "expected_remote_head": head, + "expected_dirty_paths": dirty_paths, + "expected_fingerprints": fingerprints, + "dirty_path_count": 7, + "role_kind": "author", + "notes": [ + "Distinct from #864 apply path: this fixture documents the #860 class " + "inputs (dead PID + dirty inventory) without remote sync or recovery " + "worktree creation.", + ], + } diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 04edea9..8e1661a 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -440,13 +440,32 @@ def _session_author_lock_worktree() -> str | None: Used to derive the author mutation workspace when no explicit ``worktree_path`` or env binding is provided. Never invents a path. + + #864: a session pointer whose owner PID is dead and is not this process + must not force workspace binding for other issues — rebind is required for + that issue, and a stale dead-owner pointer must not poison unrelated work. """ try: lock = issue_lock_store.read_session_issue_lock() or {} except Exception: return None path = (lock.get("worktree_path") or "").strip() - return path or None + if not path: + return None + pid = lock.get("session_pid") + if pid is None: + pid = lock.get("pid") + try: + pid_i = int(pid) if pid is not None else None + except (TypeError, ValueError): + pid_i = None + if ( + pid_i is not None + and pid_i != os.getpid() + and not issue_lock_store.is_process_alive(pid_i) + ): + return None + return path def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: @@ -2031,6 +2050,7 @@ import issue_lock_store # noqa: E402 import issue_lock_adoption # noqa: E402 import issue_lock_recovery # noqa: E402 import issue_lock_renewal # noqa: E402 +import dirty_same_claimant_session_rebind # noqa: E402 # #864 import stacked_pr_support # noqa: E402 import merge_approval_gate # noqa: E402 import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine @@ -4342,6 +4362,263 @@ def gitea_lock_issue( return result +@mcp.tool() +def gitea_rebind_dirty_same_claimant_author_session( + issue_number: int, + branch_name: str, + worktree_path: str, + old_pid: int, + expected_local_head: str, + expected_remote_head: str, + expected_dirty_paths: list[str], + expected_fingerprints: dict, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + dry_run: bool = False, + authorize_reconciler_execute: bool = False, +) -> dict: + """Rebind a dirty registered issue worktree to this session (#864). + + Sanctioned only when every pin agrees: same claimant, dead old_pid matching + the durable lock, matching local/remote heads, exact dirty path set, and + per-path sha256 fingerprints. Preserves every tracked/untracked byte. + Does not sync remote, create recovery worktrees, clean, reset, or move heads. + + Role gate: + * author — must match the lock claimant identity/profile + * reconciler — execute only when ``authorize_reconciler_execute=True`` + * reviewer/merger — always refuse + + ``gitea.issue.comment`` (author map entry) is required for mutation; dry_run + still assesses fully but writes nothing. Permission alone is never ownership + proof — every pin is re-checked server-side. + + Args: + issue_number: Tracking issue number on the durable lock. + branch_name: Exact locked branch name. + worktree_path: Registered dirty worktree path (must be under branches/). + old_pid: Dead owner PID recorded on the lock (must match session_pid/pid). + expected_local_head: Full local HEAD sha the caller observed. + expected_remote_head: Full remote-tracking HEAD sha the caller observed. + expected_dirty_paths: Exact set of dirty relative paths (tracked+untracked). + expected_fingerprints: Map of relative path -> sha256 hex of file bytes. + remote: Known instance — 'dadeschools' or 'prgs'. + host/org/repo: Optional target overrides (validated against binding). + dry_run: When true, assess only (no lock/session writes). + authorize_reconciler_execute: Reconciler-only execute gate. + """ + role = _profile_role_kind(get_profile()) + role_norm = (role or "").strip().lower() + + # Permission: authors need comment; dry_run assess is reachable under read + # for diagnosis, but execute always needs comment. Reconciler execute also + # needs comment when authorized. + if dry_run: + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "dry_run": True, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + else: + blocked = _profile_permission_block( + task_capability_map.required_permission( + "rebind_dirty_same_claimant_author_session" + ), + issue_number=issue_number, + remote=remote, + host=host, + org=org, + repo=repo, + org_explicit=org is not None, + repo_explicit=repo is not None, + ) + if blocked: + return blocked + + if role_norm in {"reviewer", "merger"}: + return { + "success": False, + "dry_run": bool(dry_run), + "outcome": dirty_same_claimant_session_rebind.REFUSED, + "reasons": [ + f"role '{role_norm}' cannot rebind dirty same-claimant author " + "sessions (fail closed)" + ], + } + if role_norm == "reconciler" and not authorize_reconciler_execute and not dry_run: + return { + "success": False, + "dry_run": False, + "outcome": dirty_same_claimant_session_rebind.REFUSED, + "reasons": [ + "reconciler role requires authorize_reconciler_execute=True " + "to execute dirty same-claimant rebind (fail closed)" + ], + } + + h, o, r = _resolve(remote, host, org, repo) + try: + identity = _authenticated_username(h) + except Exception: + identity = None + profile = get_profile() + profile_name = profile.get("profile_name") + + existing = _load_existing_issue_lock( + remote=remote, org=o, repo=r, issue_number=issue_number + ) + resolved_wt = os.path.realpath(os.path.abspath((worktree_path or "").strip())) + inv = dirty_same_claimant_session_rebind.collect_dirty_inventory(resolved_wt) + + branch_res = subprocess.run( + ["git", "-C", resolved_wt, "branch", "--show-current"], + capture_output=True, + text=True, + check=False, + ) + current_branch = (branch_res.stdout or "").strip() or None + head_res = subprocess.run( + ["git", "-C", resolved_wt, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + local_head = (head_res.stdout or "").strip() if head_res.returncode == 0 else None + + # Observe remote-tracking head without network when possible. + remote_head = None + for ref in ( + f"refs/remotes/origin/{branch_name}", + f"origin/{branch_name}", + f"refs/remotes/{remote}/{branch_name}", + f"{remote}/{branch_name}", + ): + rh = subprocess.run( + ["git", "-C", resolved_wt, "rev-parse", "--verify", "--quiet", ref], + capture_output=True, + text=True, + check=False, + ) + if rh.returncode == 0 and (rh.stdout or "").strip(): + remote_head = (rh.stdout or "").strip() + break + if remote_head is None: + # Fall back to caller's pin only for observation absence — assessment + # still requires pin==observed, so missing observation fails closed. + remote_head = None + + # Competing live locks (other issues / other worktrees). + competing_live = [] + for entry in issue_lock_store.list_live_locks(): + competing_live.append(entry) + + # Session pointers that claim this issue lock. + competing_sessions = [] + lock_dir = issue_lock_store.default_lock_dir() + lock_path = issue_lock_store.lock_file_path( + remote=remote, org=o, repo=r, issue_number=issue_number, lock_dir=lock_dir + ) + try: + for name in os.listdir(lock_dir): + if not name.startswith("session-") or not name.endswith(".json"): + continue + ptr = issue_lock_store.read_lock_file(os.path.join(lock_dir, name)) + if not ptr: + continue + ptr_lock = str(ptr.get("lock_file_path") or "").strip() + if not ptr_lock: + continue + try: + same = os.path.realpath(ptr_lock) == os.path.realpath(lock_path) + except OSError: + same = ptr_lock == lock_path + if not same: + continue + try: + sess_pid = int(str(name)[len("session-") : -len(".json")]) + except ValueError: + sess_pid = ptr.get("pid") + competing_sessions.append( + { + "pid": sess_pid, + "lock_file_path": ptr_lock, + "live": issue_lock_store.is_process_alive(sess_pid), + } + ) + except OSError: + pass + + # Best-effort workflow-lease scan: any live lock file whose work_lease is a + # non-author workflow lease on this issue/branch counts as active. + workflow_lease_active = False + for path in issue_lock_store.iter_lock_files(lock_dir): + rec = issue_lock_store.read_lock_file(path) + if not rec: + continue + lease = rec.get("work_lease") if isinstance(rec.get("work_lease"), dict) else {} + op = str(lease.get("operation_type") or "") + if op and op != issue_lock_store.AUTHOR_ISSUE_WORK_LEASE: + if rec.get("issue_number") == issue_number or str( + rec.get("branch_name") or "" + ) == branch_name: + if issue_lock_store.is_lease_live(rec): + workflow_lease_active = True + break + + repo_root = _canonical_local_git_root() + # permission_allowed reflects profile gate only — never ownership proof. + permission_allowed = True + + result = dirty_same_claimant_session_rebind.apply_dirty_same_claimant_session_rebind( + remote=remote, + org=o, + repo=r, + issue_number=issue_number, + branch_name=branch_name, + worktree_path=resolved_wt, + claimant_identity=identity, + claimant_profile=profile_name, + old_pid=old_pid, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_paths=list(expected_dirty_paths or []), + expected_fingerprints=dict(expected_fingerprints or {}), + existing_lock=existing, + current_identity=identity, + current_profile=profile_name, + role_kind=role_norm or role, + current_pid=os.getpid(), + current_branch=current_branch, + local_head=local_head, + remote_head=remote_head, + dirty_inventory=inv, + competing_live_locks=competing_live, + competing_sessions=competing_sessions, + workflow_lease_active=workflow_lease_active, + authorize_reconciler_execute=bool(authorize_reconciler_execute), + permission_allowed=permission_allowed, + repo_root=repo_root, + dry_run=bool(dry_run), + lock_dir=lock_dir, + ) + result["observed"] = { + "local_head": local_head, + "remote_head": remote_head, + "current_branch": current_branch, + "dirty_paths": inv.get("dirty_paths"), + "fingerprints": inv.get("fingerprints"), + "identity": identity, + "profile": profile_name, + "role_kind": role_norm, + } + return result + + @mcp.tool() def gitea_assess_work_issue_duplicate( issue_number: int, diff --git a/issue_lock_provenance.py b/issue_lock_provenance.py index 87ee38f..544e017 100644 --- a/issue_lock_provenance.py +++ b/issue_lock_provenance.py @@ -16,11 +16,16 @@ ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock SOURCE_LOCK_ISSUE = "gitea_lock_issue" SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" SOURCE_OPERATOR_OVERRIDE = "operator_override" +# #864: dirty-preserving same-claimant author-session rebind (dead owner PID). +SOURCE_DIRTY_SAME_CLAIMANT_REBIND = ( + "gitea_rebind_dirty_same_claimant_author_session" +) SANCTIONED_LOCK_SOURCES = frozenset({ SOURCE_LOCK_ISSUE, SOURCE_LOCK_ADOPTION, SOURCE_OPERATOR_OVERRIDE, + SOURCE_DIRTY_SAME_CLAIMANT_REBIND, }) _OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" diff --git a/task_capability_map.py b/task_capability_map.py index 0b8ac0b..7d7b76b 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -32,6 +32,17 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "role": "author", }, + # #864: dirty-preserving same-claimant author-session rebind (dead owner PID). + # Author MCP tool path. Reconciler execute is gated inside the tool via + # authorize_reconciler_execute + role_kind checks (not this map entry). + "rebind_dirty_same_claimant_author_session": { + "permission": "gitea.issue.comment", + "role": "author", + }, + "gitea_rebind_dirty_same_claimant_author_session": { + "permission": "gitea.issue.comment", + "role": "author", + }, "set_issue_labels": { "permission": "gitea.issue.comment", "role": "author", diff --git a/tests/test_dirty_same_claimant_session_rebind.py b/tests/test_dirty_same_claimant_session_rebind.py new file mode 100644 index 0000000..7208e5f --- /dev/null +++ b/tests/test_dirty_same_claimant_session_rebind.py @@ -0,0 +1,845 @@ +"""Integration tests for dirty same-claimant author-session rebind (#864). + +Uses real temp git repos/worktrees and a temp GITEA_ISSUE_LOCK_DIR. Does not +mutate any real #860/#864 worktree on disk. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import dirty_same_claimant_session_rebind as rebind # noqa: E402 +import issue_lock_provenance # noqa: E402 +import issue_lock_store as ils # noqa: E402 +import issue_lock_worktree # noqa: E402 + +ISSUE = 864 +BRANCH = f"fix/issue-{ISSUE}-dirty-same-claimant-session-rebind" +REMOTE = "prgs" +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" +IDENTITY = "jcwalker3" +PROFILE = "prgs-author" + + +def _git(cwd: str, *args: str, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + check=check, + ) + + +def dead_pid() -> int: + proc = subprocess.Popen([sys.executable, "-c", "pass"]) + proc.wait() + return proc.pid + + +def future_ts(hours: int = 4) -> str: + return ( + (datetime.now(timezone.utc) + timedelta(hours=hours)) + .isoformat() + .replace("+00:00", "Z") + ) + + +@pytest.fixture +def lock_dir(tmp_path, monkeypatch): + d = tmp_path / "issue-locks" + d.mkdir() + monkeypatch.setenv("GITEA_ISSUE_LOCK_DIR", str(d)) + return str(d) + + +@pytest.fixture +def dirty_repo(tmp_path): + """Canonical repo root with branches/ worktree and dirty content.""" + root = tmp_path / "repo" + root.mkdir() + main = root / "main" + main.mkdir() + subprocess.run(["git", "init", "-q", str(main)], check=True, capture_output=True) + _git(str(main), "config", "user.email", "t@t") + _git(str(main), "config", "user.name", "t") + (main / "README.md").write_text("base\n", encoding="utf-8") + _git(str(main), "add", "README.md") + _git(str(main), "commit", "-q", "-m", "base") + _git(str(main), "branch", "-M", "master") + + # Bare remote + origin tracking so remote head is observable offline. + bare = tmp_path / "remote.git" + subprocess.run( + ["git", "init", "--bare", "-q", str(bare)], check=True, capture_output=True + ) + _git(str(main), "remote", "add", "origin", str(bare)) + _git(str(main), "push", "-q", "origin", "master:master") + + branches = root / "branches" + branches.mkdir() + wt_name = f"fix-issue-{ISSUE}-dirty-same-claimant-session-rebind" + wt = branches / wt_name + _git(str(main), "worktree", "add", "-q", "-b", BRANCH, str(wt)) + _git(str(wt), "push", "-q", "-u", "origin", BRANCH) + + # Seed committed files we will dirty. + tracked = [ + "dirty_same_claimant_session_rebind.py", + "issue_lock_provenance.py", + "task_capability_map.py", + ] + for rel in tracked: + p = wt / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"seed {rel}\n", encoding="utf-8") + _git(str(wt), "add", *tracked) + _git(str(wt), "commit", "-q", "-m", "seed tracked") + _git(str(wt), "push", "-q", "origin", BRANCH) + + # Dirty tracked + untracked. + for rel in tracked: + (wt / rel).write_text(f"dirty {rel}\n", encoding="utf-8") + untracked = [ + "tests/test_dirty_same_claimant_session_rebind.py", + "docs/runbook-dirty-rebind.md", + "scratch/notes-untracked.txt", + "extra_untracked.txt", + ] + for rel in untracked: + p = wt / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"untracked {rel}\n", encoding="utf-8") + + inv = rebind.collect_dirty_inventory(str(wt)) + assert inv["ok"], inv.get("reasons") + head = _git(str(wt), "rev-parse", "HEAD").stdout.strip() + remote_head = _git( + str(wt), "rev-parse", f"refs/remotes/origin/{BRANCH}" + ).stdout.strip() + assert head == remote_head + + return { + "root": str(root), + "main": str(main), + "worktree": str(wt), + "branch": BRANCH, + "inventory": inv, + "local_head": head, + "remote_head": remote_head, + "dirty_paths": list(inv["dirty_paths"]), + "fingerprints": dict(inv["fingerprints"]), + } + + +def _make_lock( + *, + worktree: str, + pid: int, + lock_dir: str, + identity: str = IDENTITY, + profile: str = PROFILE, + **overrides, +) -> dict: + lease = { + "operation_type": ils.AUTHOR_ISSUE_WORK_LEASE, + "issue_number": ISSUE, + "branch": BRANCH, + "worktree_path": worktree, + "claimant": {"username": identity, "profile": profile}, + "created_at": "2026-01-01T00:00:00Z", + "expires_at": future_ts(), + "last_heartbeat_at": "2026-01-01T00:00:00Z", + } + lock = { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": worktree, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "session_pid": pid, + "pid": pid, + "work_lease": lease, + "lock_generation": 1, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant={"username": identity, "profile": profile}, + ), + } + lock.update(overrides) + path = ils.lock_file_path( + remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir + ) + lock["lock_file_path"] = path + ils.save_lock_file(path, lock) + # Stale session pointer for the dead owner. + ptr = { + "pid": pid, + "lock_file_path": path, + "issue_number": ISSUE, + "branch_name": BRANCH, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + } + ils.save_lock_file(os.path.join(lock_dir, f"session-{pid}.json"), ptr) + return ils.read_lock_file(path) or lock + + +def _apply_kwargs(repo, lock, lock_dir, **overrides): + kwargs = { + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": repo["worktree"], + "claimant_identity": IDENTITY, + "claimant_profile": PROFILE, + "old_pid": lock.get("session_pid") or lock.get("pid"), + "expected_local_head": repo["local_head"], + "expected_remote_head": repo["remote_head"], + "expected_dirty_paths": repo["dirty_paths"], + "expected_fingerprints": repo["fingerprints"], + "existing_lock": lock, + "current_identity": IDENTITY, + "current_profile": PROFILE, + "role_kind": "author", + "current_pid": os.getpid(), + "current_branch": BRANCH, + "local_head": repo["local_head"], + "remote_head": repo["remote_head"], + "dirty_inventory": repo["inventory"], + "competing_live_locks": [], + "competing_sessions": [], + "workflow_lease_active": False, + "repo_root": repo["root"], + "dry_run": False, + "lock_dir": lock_dir, + } + kwargs.update(overrides) + return kwargs + + +# ── 1. Successful dead-PID same-claimant dirty rebind ─────────────────────── + + +def test_successful_dead_pid_same_claimant_dirty_rebind(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + assert result["outcome"] == rebind.REBIND_SANCTIONED + assert result["old_pid"] == old + assert result["new_pid"] == os.getpid() + assert result["generation_after"] == result["generation_before"] + 1 + + rebound = ils.read_lock_file(result["lock_path"]) + assert rebound is not None + assert int(rebound["session_pid"]) == os.getpid() + assert int(rebound["pid"]) == os.getpid() + assert ( + rebound.get("lock_provenance", {}).get("source") + == issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND + ) + assert rebound.get("rebind_record", {}).get("old_pid") == old + + +# ── 2. Byte-for-byte preservation ─────────────────────────────────────────── + + +def test_byte_for_byte_preservation(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + before = { + rel: rebind.content_fingerprint(os.path.join(dirty_repo["worktree"], rel)) + for rel in dirty_repo["dirty_paths"] + } + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + after = { + rel: rebind.content_fingerprint(os.path.join(dirty_repo["worktree"], rel)) + for rel in dirty_repo["dirty_paths"] + } + assert before == after + assert result["fingerprints"] == before + + +# ── 3. Exact dirty-path and fingerprint enforcement ───────────────────────── + + +def test_extra_dirty_path_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + pins = list(dirty_repo["dirty_paths"])[:-1] # missing one observed path + fps = {p: dirty_repo["fingerprints"][p] for p in pins} + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_dirty_paths=pins, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("unexpected paths" in r for r in result["reasons"]) + + +def test_missing_expected_dirty_path_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + pins = list(dirty_repo["dirty_paths"]) + ["not_really_dirty.txt"] + fps = dict(dirty_repo["fingerprints"]) + fps["not_really_dirty.txt"] = "0" * 64 + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_dirty_paths=pins, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("missing expected" in r for r in result["reasons"]) + + +def test_modified_fingerprint_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + fps = dict(dirty_repo["fingerprints"]) + victim = dirty_repo["dirty_paths"][0] + fps[victim] = "f" * 64 + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("fingerprint disagreement" in r for r in result["reasons"]) + + +# ── 4. Atomic session-pointer replacement ─────────────────────────────────── + + +def test_session_pointer_points_to_lock(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + new_ptr_path = os.path.join(lock_dir, f"session-{os.getpid()}.json") + assert os.path.exists(new_ptr_path) + ptr = ils.read_lock_file(new_ptr_path) + assert ptr is not None + assert os.path.realpath(ptr["lock_file_path"]) == os.path.realpath( + result["lock_path"] + ) + # Old pointer removed when it targeted this lock. + old_ptr = os.path.join(lock_dir, f"session-{old}.json") + assert not os.path.exists(old_ptr) + assert result.get("removed_old_session_pointer") is True + + +# ── 5. Retry after interruption (journal mid-state) ───────────────────────── + + +def test_retry_after_journal_mid_state(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + { + "phase": rebind.JOURNAL_PHASE_PRE_BIND, + "issue_number": ISSUE, + "old_pid": old, + "new_pid": os.getpid(), + "expected_generation": 1, + }, + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + assert result["journal_phase"] == rebind.JOURNAL_PHASE_COMPLETE + + # Second apply is already_rebound (retry-safe). + rebound_lock = ils.read_lock_file(result["lock_path"]) + result2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + rebound_lock, + lock_dir, + old_pid=old, + existing_lock=rebound_lock, + ) + ) + assert result2["success"], result2 + assert result2["already_rebound"] is True + + +# ── 6. Active-PID refusal ─────────────────────────────────────────────────── + + +def test_active_pid_refused(dirty_repo, lock_dir): + live = os.getpid() + # Use a different "current" identity of session via fake current_pid... + # Owner is live (this process). Rebind must refuse. + lock = _make_lock(worktree=dirty_repo["worktree"], pid=live, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=live, + current_pid=live + 10_000_000, # distinct "new" session id for pin check + ) + ) + assert not result["success"] + assert any("still alive" in r for r in result["reasons"]) + + +# ── 7. Foreign claimant refusal ───────────────────────────────────────────── + + +def test_foreign_claimant_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + current_identity="someone-else", + claimant_identity="someone-else", + ) + ) + assert not result["success"] + assert any("foreign claimant" in r or "does not match" in r for r in result["reasons"]) + + +# ── 8. Profile mismatch refusal ───────────────────────────────────────────── + + +def test_profile_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + current_profile="other-profile", + claimant_profile="other-profile", + ) + ) + assert not result["success"] + assert any("profile" in r for r in result["reasons"]) + + +# ── 9. Competing session/lock/lease refusal ───────────────────────────────── + + +def test_competing_live_lock_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + competing = [ + { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": dirty_repo["worktree"] + "-other", + "pid": os.getpid(), + } + ] + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_live_locks=competing, + ) + ) + assert not result["success"] + assert any("competing live lock" in r for r in result["reasons"]) + + +def test_competing_session_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_sessions=[ + {"pid": os.getpid(), "lock_file_path": lock["lock_file_path"], "live": True} + ], + ) + ) + # current_pid is os.getpid(), so same session is skipped — use another live pid. + # Spawn a long-lived process to act as competing live session. + rival = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_sessions=[ + { + "pid": rival.pid, + "lock_file_path": lock["lock_file_path"], + "live": True, + } + ], + ) + ) + assert not result["success"] + assert any("competing live session" in r for r in result["reasons"]) + finally: + rival.kill() + rival.wait() + + +def test_workflow_lease_active_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + workflow_lease_active=True, + ) + ) + assert not result["success"] + assert any("workflow lease" in r for r in result["reasons"]) + + +# ── 10. Local- and remote-head movement refusal ───────────────────────────── + + +def test_local_head_movement_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_local_head="b" * 40, + ) + ) + assert not result["success"] + assert any("local head" in r for r in result["reasons"]) + + +def test_remote_head_movement_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_remote_head="c" * 40, + ) + ) + assert not result["success"] + assert any("remote head" in r for r in result["reasons"]) + + +# ── 11. Path/symlink/registration mismatches ──────────────────────────────── + + +def test_worktree_not_under_branches_refused(dirty_repo, lock_dir, tmp_path): + old = dead_pid() + # Use a path outside branches/ as the declared worktree (still real dir). + outside = tmp_path / "outside-wt" + outside.mkdir() + lock = _make_lock(worktree=str(outside), pid=old, lock_dir=lock_dir) + # Inventory empty for outside path; use empty pins to hit path gate first + # by providing matching empty-ish inventory after we force path checks. + inv = { + "dirty_paths": dirty_repo["dirty_paths"], + "fingerprints": dirty_repo["fingerprints"], + "ok": True, + "reasons": [], + } + result = rebind.assess_dirty_same_claimant_session_rebind( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=str(outside), + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + old_pid=old, + expected_local_head=dirty_repo["local_head"], + expected_remote_head=dirty_repo["remote_head"], + expected_dirty_paths=dirty_repo["dirty_paths"], + expected_fingerprints=dirty_repo["fingerprints"], + existing_lock=lock, + current_identity=IDENTITY, + current_profile=PROFILE, + role_kind="author", + current_pid=os.getpid(), + current_branch=BRANCH, + local_head=dirty_repo["local_head"], + remote_head=dirty_repo["remote_head"], + dirty_inventory=inv, + repo_root=dirty_repo["root"], + ) + assert not result["rebind_sanctioned"] + assert any("branches/" in r for r in result["reasons"]) + + +def test_lock_worktree_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock( + worktree=dirty_repo["worktree"] + "-elsewhere", + pid=old, + lock_dir=lock_dir, + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("does not match declared" in r for r in result["reasons"]) + + +# ── 12. Malformed lock/session records ────────────────────────────────────── + + +def test_malformed_lock_missing_pid_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + lock.pop("session_pid", None) + lock.pop("pid", None) + ils.save_lock_file(lock["lock_file_path"], lock) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("incomplete" in r or "session_pid" in r for r in result["reasons"]) + + +def test_empty_old_pid_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=None) + ) + assert not result["success"] + assert any("old_pid" in r for r in result["reasons"]) + + +def test_reviewer_role_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, role_kind="reviewer") + ) + assert not result["success"] + assert any("reviewer" in r for r in result["reasons"]) + + +def test_reconciler_without_authorize_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + role_kind="reconciler", + authorize_reconciler_execute=False, + ) + ) + assert not result["success"] + assert any("authorize_reconciler_execute" in r for r in result["reasons"]) + + +# ── 13. No duplicate ownership after success or retry ─────────────────────── + + +def test_no_duplicate_ownership_after_success_or_retry(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + r1 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert r1["success"], r1 + rebound = ils.read_lock_file(r1["lock_path"]) + r2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, rebound, lock_dir, old_pid=old, existing_lock=rebound) + ) + assert r2["success"], r2 + assert r2["already_rebound"] is True + + # Only one durable lock file for this issue; session pointer is current pid. + # Skip session pointers and rebind journals (dotfiles / non-lock records). + matching = [] + for p in ils.iter_lock_files(lock_dir): + name = os.path.basename(p) + if name.startswith(".") or name.startswith("session-"): + continue + rec = ils.read_lock_file(p) + if not rec: + continue + if ( + rec.get("issue_number") == ISSUE + and rec.get("remote") == REMOTE + and rec.get("branch_name") == BRANCH + and rec.get("session_pid") is not None + ): + matching.append(rec) + assert len(matching) == 1 + assert int(matching[0]["session_pid"]) == os.getpid() + # No live session pointer for the dead old pid. + assert not os.path.exists(os.path.join(lock_dir, f"session-{old}.json")) + + +# ── 14. Ordinary dirty-worktree locking remains fail-closed ───────────────── + + +def test_ordinary_dirty_lock_worktree_assessment_blocks(dirty_repo): + porcelain = dirty_repo["inventory"]["porcelain_status"] + assessment = issue_lock_worktree.assess_issue_lock_worktree( + worktree_path=dirty_repo["worktree"], + current_branch=BRANCH, + porcelain_status=porcelain, + base_equivalent=False, + ) + assert assessment["block"] is True + assert any( + "tracked file edits exist before issue lock" in r + for r in assessment["reasons"] + ) + + +# ── 15. Fixture matching #860 class with 7 fingerprint-pinned dirty paths ─── + + +def test_issue_860_regression_fixture_spec(): + spec = rebind.build_issue_860_regression_fixture_spec() + assert spec["claimant_identity"] == "jcwalker3" + assert spec["claimant_profile"] == "prgs-author" + assert spec["old_pid_alive"] is False + assert spec["live_session_pointer"] is None + assert spec["dirty_path_count"] == 7 + assert len(spec["expected_dirty_paths"]) == 7 + assert len(spec["expected_fingerprints"]) == 7 + assert spec["expected_local_head"] == spec["expected_remote_head"] + for path in spec["expected_dirty_paths"]: + assert path in spec["expected_fingerprints"] + assert len(spec["expected_fingerprints"][path]) == 64 + + +def test_dry_run_does_not_write(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + before = ils.read_lock_file(lock["lock_file_path"]) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, dry_run=True) + ) + assert result["success"], result + assert result["dry_run"] is True + after = ils.read_lock_file(lock["lock_file_path"]) + assert after["session_pid"] == before["session_pid"] + assert not os.path.exists(os.path.join(lock_dir, f"session-{os.getpid()}.json")) + + +def test_provenance_source_is_sanctioned(): + assert ( + issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND + in issue_lock_provenance.SANCTIONED_LOCK_SOURCES + ) + assessment = issue_lock_provenance.assess_lock_file_for_create_pr( + { + "work_lease": {"operation_type": "author_issue_work"}, + "lock_provenance": { + "source": issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + "written_by_tool": issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + "written_at": "2026-01-01T00:00:00Z", + }, + } + ) + assert assessment["proven"] is True + + +def test_permission_allowed_is_not_ownership_proof(dirty_repo, lock_dir): + """permission_allowed=True must not bypass foreign claimant refusal.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.assess_dirty_same_claimant_session_rebind( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=dirty_repo["worktree"], + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + old_pid=old, + expected_local_head=dirty_repo["local_head"], + expected_remote_head=dirty_repo["remote_head"], + expected_dirty_paths=dirty_repo["dirty_paths"], + expected_fingerprints=dirty_repo["fingerprints"], + existing_lock=lock, + current_identity="intruder", + current_profile=PROFILE, + role_kind="author", + current_pid=os.getpid(), + current_branch=BRANCH, + local_head=dirty_repo["local_head"], + remote_head=dirty_repo["remote_head"], + dirty_inventory=dirty_repo["inventory"], + permission_allowed=True, + repo_root=dirty_repo["root"], + ) + assert not result["rebind_sanctioned"] + assert any("does not match active identity" in r for r in result["reasons"]) + + +def test_content_fingerprint_stable(tmp_path): + p = tmp_path / "f.txt" + p.write_bytes(b"abc123") + a = rebind.content_fingerprint(str(p)) + b = rebind.content_fingerprint(str(p)) + assert a == b + assert len(a) == 64 From 24c52abf6b80e7ed562294371eee9fe8620b686e Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 24 Jul 2026 00:49:52 -0400 Subject: [PATCH 14/19] feat(reconciler): PR-scoped post-merge cleanup executor + expired reviewer-lease reclaim (Closes #855) Adds a single-target path for post-merge cleanup so a reconciler can complete one merged PR without a batch sweep across unrelated PRs. ## PR-scoped selector gitea_reconcile_merged_cleanups gains an optional pr_number. When set, only that merged PR is assessed and acted on: the PR is resolved live and fails closed on an invalid/non-positive number, an unresolvable or ambiguous PR, or an unmerged PR; reviewer scratch worktrees are filtered to that PR; and the report's entry set is pinned to exactly [pr_number], failing closed on any drift. The existing execute loop then operates on the single pinned entry only -- worktree removal, ownership reassessment, then remote-branch delete -- with no unrelated target. Batch behaviour is unchanged when pr_number is omitted. ## Expired reviewer-lease reclaim (AC4) An expired or stale reviewer lease no longer protects an already-merged branch forever. branch_cleanup_guard.assess_expired_reviewer_lease_reclaim makes the decision explicitly and fail-closed: reclaim only when the lease is a reviewer lease, its status is expired/stale, the PR is proven merged, the owner process is proven dead, and no competing active claimant uses the branch. _collect_branch_ownership_records supplies that evidence from authoritative state (live PR merged-state, lease owner liveness, and the full ownership inventory for competing-claimant detection) and evaluates it only after the complete inventory is built, so the post-worktree-removal reassessment is what unblocks the branch delete. Any unknown fails closed. Author/merger/controller/reconciler leases are untouched; active leases, worktree bindings, issue locks, and live sessions still block. ## Tests - tests/test_issue_855_expired_reviewer_reclaim.py: full fail-closed matrix for the reclaim decision plus collector wiring (merged+dead+uncontested reclaims; unmerged, live-owner, competing-worktree, and author-lease cases stay protective). - tests/test_branch_cleanup_guard.py: exact-PR selector coverage (ignores newer PRs in the batch queue, execute mutates only the selected PR, unknown/not-merged/invalid fail closed, batch mode preserved). Changed-surface suites pass; the 2 pre-existing test_branch_cleanup_guard failures and test_reconciler_supersession_close reproduce identically on master 6d0015ca and are unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- branch_cleanup_guard.py | 84 +++++ gitea_mcp_server.py | 224 ++++++++++- tests/test_branch_cleanup_guard.py | 352 ++++++++++++++++++ ...test_issue_855_expired_reviewer_reclaim.py | 244 ++++++++++++ 4 files changed, 893 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue_855_expired_reviewer_reclaim.py diff --git a/branch_cleanup_guard.py b/branch_cleanup_guard.py index 64db84a..e91ea02 100644 --- a/branch_cleanup_guard.py +++ b/branch_cleanup_guard.py @@ -525,6 +525,90 @@ def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]: } +# Reviewer-lease reclaim is only reachable from a non-live (expired/stale) lease. +_RECLAIMABLE_REVIEWER_STATUSES = _EXPIRED_STATUSES | _STALE_STATUSES + + +def is_active_ownership_status(status: str | None) -> bool: + """True when *status* denotes live/active ownership of a branch (#855). + + Used to decide whether a *competing* active claimant still uses a branch + when weighing an expired reviewer lease for reclaim. Expired, stale, + released, and terminal statuses are not active. + """ + return _norm_str(status).lower() in _ACTIVE_OWNERSHIP_STATUSES + + +def assess_expired_reviewer_lease_reclaim( + *, + role: str, + status: str, + pr_merged: bool | None, + owner_pid_alive: bool | None, + competing_active_claimant: bool | None, +) -> dict[str, Any]: + """Decide, explicitly and fail-closed, whether an expired reviewer lease + may stop protecting an already-merged branch (#855 AC4). + + An expired reviewer lease should not protect a merged branch forever once + its work is done and no live claimant remains. Reclaim is permitted only + when **every** condition below is provably satisfied; any unknown + (``None``) or contrary value keeps the lease protective: + + - the lease is a ``reviewer`` lease (author/merger/controller/reconciler + leases are out of scope and always keep protecting); + - its status is expired or stale (never an active/live lease); + - the PR is proven merged (``pr_merged is True``); + - the lease owner process is proven dead (``owner_pid_alive is False``); + - no competing active claimant uses the branch + (``competing_active_claimant is False``). + + Returns a decision dict with ``reclaim_allowed`` and, when refused, the + fail-closed ``reasons``. The reasons never contain secrets — only the + role, the status, and which condition was unproven. + """ + reasons: list[str] = [] + normalized_role = _norm_str(role).lower() + normalized_status = _norm_str(status).lower() + + if normalized_role != "reviewer": + reasons.append( + f"lease role '{normalized_role or 'unknown'}' is not a reviewer " + "lease; expired-reviewer reclaim does not apply" + ) + if normalized_status not in _RECLAIMABLE_REVIEWER_STATUSES: + reasons.append( + f"lease status '{normalized_status or 'unknown'}' is not expired " + "or stale; only a non-live reviewer lease may be reclaimed" + ) + if pr_merged is not True: + reasons.append( + "PR merged state is not proven true; reclaim requires an " + "already-merged PR (fail closed)" + ) + if owner_pid_alive is not False: + reasons.append( + "lease owner process liveness is not proven dead; a live owner " + "still protects the branch (fail closed)" + ) + if competing_active_claimant is not False: + reasons.append( + "a competing active claimant may still use the branch; reclaim " + "requires no other active ownership (fail closed)" + ) + + allowed = not reasons + return { + "reclaim_allowed": allowed, + "role": normalized_role, + "status": normalized_status, + "decision": ( + "reclaim_expired_reviewer_lease" if allowed else "keep_protecting" + ), + "reasons": [] if allowed else reasons, + } + + def assess_active_branch_ownership( *, remote: str, diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 04edea9..aebfdd2 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -10839,6 +10839,9 @@ def _collect_branch_ownership_records( """ records: list[dict] = [] inventory_error = False + # #855 AC4: expired/stale reviewer-lease records eligible for an explicit + # reclaim decision, evaluated after the full ownership inventory is built. + reviewer_reclaim_candidates: list[tuple[dict, bool | None]] = [] target_branch = (branch or "").strip() if not target_branch: return {"records": records, "inventory_error": False} @@ -10987,15 +10990,28 @@ def _collect_branch_ownership_records( else: status = freshness_status reclaim_allowed = False - records.append( - _base_rec( - category=category, - status=status, - reclaim_allowed=reclaim_allowed, - role=role, - host=lease_host or host_n or host, - ) + rec = _base_rec( + category=category, + status=status, + reclaim_allowed=reclaim_allowed, + role=role, + host=lease_host or host_n or host, ) + records.append(rec) + # #855 AC4: a reviewer lease that is expired/stale (its owner + # gone) becomes a candidate for an explicit, fail-closed + # reclaim decision made once the full inventory is known. + if ( + role == "reviewer" + and status + in branch_cleanup_guard._RECLAIMABLE_REVIEWER_STATUSES + ): + owner_alive = ( + fr.get("owner_pid_alive") if isinstance(fr, dict) else None + ) + reviewer_reclaim_candidates.append( + (rec, owner_alive if isinstance(owner_alive, bool) else None) + ) except Exception: # O1: fail closed on control-plane inventory errors. inventory_error = True @@ -11066,6 +11082,44 @@ def _collect_branch_ownership_records( ) ) + # #855 AC4: decide, explicitly and fail-closed, whether any expired/stale + # reviewer lease may stop protecting an already-merged branch. This runs + # only after the full ownership inventory is built, so a competing active + # claimant (an active lease, author session, worktree binding, or active + # reviewer comment lease) is visible. An inventory failure keeps every + # reclaim candidate protective (reclaim_allowed stays False). + if reviewer_reclaim_candidates and not inventory_error: + pr_merged_state: bool | None = None + if pr_number is not None and auth and base_api: + try: + pr_live = api_request( + "GET", f"{base_api}/pulls/{int(pr_number)}", auth + ) + if isinstance(pr_live, dict) and pr_live: + pr_merged_state = bool( + pr_live.get("merged") or pr_live.get("merged_at") + ) + except Exception: + # Unknown merged state fails closed (candidate stays protective). + pr_merged_state = None + for cand_rec, owner_alive in reviewer_reclaim_candidates: + competing = any( + other is not cand_rec + and branch_cleanup_guard.is_active_ownership_status( + other.get("status") + ) + for other in records + ) + decision = branch_cleanup_guard.assess_expired_reviewer_lease_reclaim( + role=str(cand_rec.get("role")), + status=str(cand_rec.get("status")), + pr_merged=pr_merged_state, + owner_pid_alive=owner_alive, + competing_active_claimant=competing, + ) + cand_rec["reclaim_allowed"] = decision["reclaim_allowed"] + cand_rec["reclaim_decision"] = decision["decision"] + return {"records": records, "inventory_error": inventory_error} @@ -11128,6 +11182,7 @@ def gitea_reconcile_merged_cleanups( dry_run: bool = True, execute_confirmed: bool = False, limit: int = 50, + pr_number: int | None = None, remote: str = "dadeschools", host: str | None = None, org: str | None = None, @@ -11138,7 +11193,11 @@ def gitea_reconcile_merged_cleanups( Args: dry_run: Defaults to True. When True, only builds the reconciliation report. execute_confirmed: Must be True when dry_run=False. - limit: Max number of closed PRs to inspect. + limit: Max number of closed PRs to inspect (batch mode only; ignored when + ``pr_number`` is set). + pr_number: Optional exact merged PR selector (#855). When set, only that + PR is assessed/acted on (fail closed if missing, unmerged, or + ambiguous). When omitted, existing batch behaviour is preserved. remote: Known Gitea instance ('dadeschools' or 'prgs'). host: Override the Gitea host. org: Override the owner/organization. @@ -11173,11 +11232,120 @@ def gitea_reconcile_merged_cleanups( "audit_phase": audit_reconciliation_mode.current_phase(), } + # #855: optional exact PR pin. Fail closed before any inventory mutation. + exact_pr: int | None = None + if pr_number is not None: + try: + exact_pr = int(pr_number) + except (TypeError, ValueError): + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": "exact_pr", + "selected_pr_number": pr_number, + "reasons": [ + f"pr_number={pr_number!r} is not a valid integer " + "(fail closed; no mutation)" + ], + "blocker_kind": "invalid_pr_number", + } + if exact_pr <= 0: + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": "exact_pr", + "selected_pr_number": exact_pr, + "reasons": [ + f"pr_number={exact_pr} must be a positive integer " + "(fail closed; no mutation)" + ], + "blocker_kind": "invalid_pr_number", + } + h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) - closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit) - open_prs = api_get_all(f"{base}/pulls?state=open", auth) + + selection_mode = "batch" + closed_prs: list[dict] = [] + open_prs: list[dict] = [] + if exact_pr is not None: + selection_mode = "exact_pr" + try: + pr_live = api_request("GET", f"{base}/pulls/{exact_pr}", auth) + except Exception as exc: + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": selection_mode, + "selected_pr_number": exact_pr, + "reasons": [ + f"PR #{exact_pr} could not be uniquely resolved " + f"(fail closed; no mutation): {_redact(str(exc))}" + ], + "blocker_kind": "pr_unresolvable", + } + if not isinstance(pr_live, dict) or not pr_live: + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": selection_mode, + "selected_pr_number": exact_pr, + "reasons": [ + f"PR #{exact_pr} could not be uniquely resolved " + "(empty response; fail closed; no mutation)" + ], + "blocker_kind": "pr_unresolvable", + } + live_number = pr_live.get("number") + try: + live_number_int = int(live_number) if live_number is not None else None + except (TypeError, ValueError): + live_number_int = None + if live_number_int != exact_pr: + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": selection_mode, + "selected_pr_number": exact_pr, + "reasons": [ + f"PR #{exact_pr} resolution is ambiguous or mismatched " + f"(live number={live_number!r}; fail closed; no mutation)" + ], + "blocker_kind": "pr_ambiguous", + } + if not (pr_live.get("merged") or pr_live.get("merged_at")): + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": selection_mode, + "selected_pr_number": exact_pr, + "reasons": [ + f"PR #{exact_pr} is not merged " + "(exact-target cleanup requires a merged PR; " + "fail closed; no mutation)" + ], + "blocker_kind": "pr_not_merged", + } + closed_prs = [pr_live] + # Exact mode still needs open heads for remote-delete safety gates. + open_prs = api_get_all(f"{base}/pulls?state=open", auth) + else: + # Preserve historical call order (closed then open) for batch callers/tests. + closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit) + open_prs = api_get_all(f"{base}/pulls?state=open", auth) merged_closed: list[dict] = [] remote_branch_exists: dict[str, bool] = {} @@ -11204,6 +11372,13 @@ def gitea_reconcile_merged_cleanups( scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees( _canonical_local_git_root() ) + # #855: exact-target never inventories or mutates foreign PR scratch trees. + if exact_pr is not None: + scratch_candidates = [ + s + for s in scratch_candidates + if int(s.get("pr_number") or 0) == int(exact_pr) + ] active_reviewer_leases: dict[int, bool] = {} pr_states: dict[int, dict] = {} for scratch in scratch_candidates: @@ -11238,6 +11413,33 @@ def gitea_reconcile_merged_cleanups( active_reviewer_leases=active_reviewer_leases, pr_states=pr_states, ) + report["selection_mode"] = selection_mode + if exact_pr is not None: + report["selected_pr_number"] = exact_pr + # Fail closed if exact pin somehow produced other or zero entries. + entries = list(report.get("entries") or []) + entry_numbers = [] + for entry in entries: + try: + entry_numbers.append(int(entry.get("pr_number"))) + except (TypeError, ValueError): + entry_numbers.append(entry.get("pr_number")) + if entry_numbers != [exact_pr]: + return { + "success": False, + "performed": False, + "executed": False, + "dry_run": bool(dry_run), + "selection_mode": selection_mode, + "selected_pr_number": exact_pr, + "reasons": [ + f"exact PR #{exact_pr} selection produced unexpected " + f"candidate set {entry_numbers!r} " + "(fail closed; no mutation)" + ], + "blocker_kind": "exact_selection_mismatch", + "entries": entries, + } if dry_run: report["dry_run"] = True diff --git a/tests/test_branch_cleanup_guard.py b/tests/test_branch_cleanup_guard.py index 0bd1fda..fddb534 100644 --- a/tests/test_branch_cleanup_guard.py +++ b/tests/test_branch_cleanup_guard.py @@ -1639,6 +1639,358 @@ class TestSecondRemediationIntegration(unittest.TestCase): self.assertTrue(ownership_calls) +class TestIssue855ExactPrSelector(unittest.TestCase): + """#855: exact pr_number pin for reconcile_merged_cleanups (#851 lifecycle).""" + + 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() + patch("gitea_audit.audit_enabled", return_value=False).start() + self.mock_api = patch("mcp_server.api_request").start() + self.mock_all = patch("mcp_server.api_get_all", return_value=[]).start() + patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start() + patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ).start() + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + patch( + "mcp_server._profile_operation_gate", + return_value=[], + ).start() + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees", + return_value=[], + ).start() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch( + "mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed", + return_value=(True, []), + ).start() + + def tearDown(self): + patch.stopall() + + def _merged_pr(self, number, branch, sha="c" * 40): + return { + "number": number, + "title": f"PR {number}", + "body": f"Closes #{number - 4}", + "merged": True, + "merged_at": "2026-07-23T12:00:00Z", + "merge_commit_sha": "f" * 40, + "state": "closed", + "head": {"ref": branch, "sha": sha}, + "base": {"ref": "master"}, + } + + def test_exact_pr_848_ignores_newer_852_in_batch_queue(self): + """pr_number=848 selects only #848 even when #852 is newer/first.""" + from mcp_server import gitea_reconcile_merged_cleanups + + pr_848 = self._merged_pr( + 848, "fix/issue-844-exclude-epic-containers", sha="c3f282ba" + "0" * 32 + ) + # Closed list would rank #852 first in batch mode; exact pin must ignore it. + closed_batch = [ + self._merged_pr(852, "fix/issue-851-cleanup-worktree-before-remote-delete"), + pr_848, + self._merged_pr(849, "fix/issue-849-other"), + self._merged_pr(846, "fix/issue-846-other"), + self._merged_pr(845, "fix/issue-845-other"), + ] + batch_fetch_calls = [] + + def fake_api(method, url, *args, **kwargs): + if method == "GET" and url.rstrip("/").endswith("/pulls/848"): + return dict(pr_848) + if method == "GET" and "/pulls/" in url: + raise AssertionError(f"unexpected PR fetch: {url}") + if method == "GET" and "/branches/" in url: + return {"name": "present"} + return {} + + def fake_all(url, auth, limit=None): + batch_fetch_calls.append((url, limit)) + if "state=open" in url: + return [] + if "state=closed" in url: + # Exact mode must not use the closed batch list. + raise AssertionError( + "exact pr_number mode must not page closed PRs: " + url + ) + return [] + + self.mock_api.side_effect = fake_api + self.mock_all.side_effect = fake_all + patch( + "mcp_server._remote_branch_exists", + return_value=True, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + side_effect=lambda **kwargs: { + "entries": [ + { + "pr_number": int(pr["number"]), + "head_branch": (pr.get("head") or {}).get("ref"), + "issue_number": 844, + "remote_branch": { + "safe_to_delete_remote": True, + "head_branch": (pr.get("head") or {}).get("ref"), + }, + "local_worktree": { + "safe_to_remove_worktree": True, + "worktree_path": ( + "/tmp/branches/fix-issue-844-exclude-epic-containers" + ), + }, + "planned_execution_order": ( + mcp_server.merged_cleanup_reconcile.plan_cleanup_execution_order( + remote_assessment={"safe_to_delete_remote": True}, + local_assessment={"safe_to_remove_worktree": True}, + ) + ), + } + for pr in kwargs.get("closed_prs") or [] + if pr.get("merged_at") or pr.get("merged") + ], + "reviewer_scratch_entries": [], + "merged_pr_count": len(kwargs.get("closed_prs") or []), + }, + ).start() + + res = gitea_reconcile_merged_cleanups( + dry_run=True, + pr_number=848, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(res.get("success")) + self.assertFalse(res.get("performed")) + self.assertEqual(res.get("selection_mode"), "exact_pr") + self.assertEqual(res.get("selected_pr_number"), 848) + entries = res.get("entries") or [] + self.assertEqual(len(entries), 1, entries) + self.assertEqual(entries[0].get("pr_number"), 848) + self.assertEqual( + entries[0].get("head_branch"), + "fix/issue-844-exclude-epic-containers", + ) + # No other PR appears in plan. + self.assertEqual(list((res.get("planned_execution_orders") or {}).keys()), ["848"]) + plan = (res.get("planned_execution_orders") or {}).get("848") or [] + actions = [s.get("action") for s in plan] + self.assertEqual( + actions, + [ + "remove_local_worktree", + "reassess_branch_ownership", + "delete_remote_branch", + ], + ) + # Prove we never scanned the multi-PR closed batch. + self.assertFalse(any("state=closed" in (u or "") for u, _ in batch_fetch_calls)) + # closed_batch fixture must remain unused (sanity). + self.assertEqual(closed_batch[0]["number"], 852) + + def test_exact_pr_execute_only_mutates_selected_pr(self): + """Execute with pr_number must never touch #845/#846/#849/#852.""" + from mcp_server import gitea_reconcile_merged_cleanups + + pr_848 = self._merged_pr(848, "fix/issue-844-exclude-epic-containers") + worktree_path = "/tmp/branches/fix-issue-844-exclude-epic-containers" + remove_calls = [] + delete_api_calls = [] + ownership_branches = [] + + def fake_api(method, url, *args, **kwargs): + if method == "GET" and url.rstrip("/").endswith("/pulls/848"): + return dict(pr_848) + if method == "DELETE": + delete_api_calls.append(url) + # Forbid foreign PR branch deletion by URL content. + for forbidden in ("845", "846", "849", "852"): + self.assertNotIn(forbidden, url) + return {} + + def fake_remove(project_root, branch, worktree_path=None): + remove_calls.append({"branch": branch, "worktree_path": worktree_path}) + return { + "success": True, + "performed": True, + "message": f"removed {worktree_path}", + "worktree_path": worktree_path, + } + + def fake_collect(**kwargs): + ownership_branches.append(kwargs.get("branch")) + return {"records": [], "inventory_error": False} + + def fake_probe(h, o, r, auth, br): + return guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH + ) + + self.mock_api.side_effect = fake_api + self.mock_all.side_effect = lambda url, auth, limit=None: [] + patch("mcp_server._remote_branch_exists", return_value=True).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value={ + "entries": [ + { + "pr_number": 848, + "head_branch": "fix/issue-844-exclude-epic-containers", + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": { + "safe_to_remove_worktree": True, + "worktree_path": worktree_path, + }, + "planned_execution_order": [ + {"action": "remove_local_worktree", "phase": 1}, + {"action": "reassess_branch_ownership", "phase": 2}, + {"action": "delete_remote_branch", "phase": 3}, + ], + } + ], + "reviewer_scratch_entries": [ + # Foreign scratch must be filtered before report execute loop; + # if present here it would still be a test failure if acted on. + ], + "merged_pr_count": 1, + }, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.remove_local_worktree", + side_effect=fake_remove, + ).start() + patch( + "mcp_server._collect_branch_ownership_records", + side_effect=fake_collect, + ).start() + patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start() + + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + pr_number=848, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(res.get("performed") or res.get("executed")) + self.assertEqual(res.get("selection_mode"), "exact_pr") + self.assertEqual(res.get("selected_pr_number"), 848) + actions = res.get("actions") or [] + pr_numbers_touched = { + a.get("pr_number") for a in actions if a.get("pr_number") is not None + } + self.assertTrue(pr_numbers_touched.issubset({None, 848}) or not pr_numbers_touched) + removes = [a for a in actions if a.get("action") == "remove_local_worktree"] + deletes = [a for a in actions if a.get("action") == "delete_remote_branch"] + self.assertEqual(len(removes), 1) + self.assertEqual(remove_calls[0]["branch"], "fix/issue-844-exclude-epic-containers") + self.assertEqual(len(deletes), 1) + self.assertTrue(deletes[0].get("success")) + self.assertTrue(deletes[0].get("after_worktree_removal")) + self.assertEqual(len(delete_api_calls), 1) + self.assertEqual( + ownership_branches, ["fix/issue-844-exclude-epic-containers"] + ) + + def test_exact_pr_unknown_fails_closed_without_mutation(self): + from mcp_server import gitea_reconcile_merged_cleanups + + def fake_api(method, url, *args, **kwargs): + if method == "GET" and "/pulls/99999" in url: + raise RuntimeError("HTTP 404 Not Found") + raise AssertionError(f"unexpected API call {method} {url}") + + self.mock_api.side_effect = fake_api + res = gitea_reconcile_merged_cleanups( + dry_run=True, + pr_number=99999, + remote="prgs", + ) + self.assertFalse(res.get("success")) + self.assertFalse(res.get("performed")) + self.assertEqual(res.get("blocker_kind"), "pr_unresolvable") + self.assertIn("99999", " ".join(res.get("reasons") or [])) + + def test_exact_pr_not_merged_fails_closed(self): + from mcp_server import gitea_reconcile_merged_cleanups + + def fake_api(method, url, *args, **kwargs): + if method == "GET" and url.rstrip("/").endswith("/pulls/900"): + return { + "number": 900, + "merged": False, + "merged_at": None, + "state": "open", + "head": {"ref": "feat/x", "sha": "a" * 40}, + } + raise AssertionError(f"unexpected {method} {url}") + + self.mock_api.side_effect = fake_api + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + pr_number=900, + remote="prgs", + ) + self.assertFalse(res.get("success")) + self.assertFalse(res.get("performed")) + self.assertEqual(res.get("blocker_kind"), "pr_not_merged") + + def test_exact_pr_invalid_number_fails_closed(self): + from mcp_server import gitea_reconcile_merged_cleanups + + res = gitea_reconcile_merged_cleanups( + dry_run=True, + pr_number=0, + remote="prgs", + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("blocker_kind"), "invalid_pr_number") + self.mock_api.assert_not_called() + + def test_batch_mode_still_works_without_pr_number(self): + """Unfiltered batch path remains backward compatible.""" + from mcp_server import gitea_reconcile_merged_cleanups + + self.mock_all.side_effect = lambda url, auth, limit=None: [] + self.mock_api.side_effect = lambda *a, **k: {} + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value={ + "entries": [], + "reviewer_scratch_entries": [], + "merged_pr_count": 0, + }, + ).start() + res = gitea_reconcile_merged_cleanups(dry_run=True, remote="prgs", limit=10) + self.assertTrue(res.get("success")) + self.assertEqual(res.get("selection_mode"), "batch") + self.assertIsNone(res.get("selected_pr_number")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_issue_855_expired_reviewer_reclaim.py b/tests/test_issue_855_expired_reviewer_reclaim.py new file mode 100644 index 0000000..288b7f7 --- /dev/null +++ b/tests/test_issue_855_expired_reviewer_reclaim.py @@ -0,0 +1,244 @@ +"""#855 AC4: an expired reviewer lease must not indefinitely protect an +already-merged branch when no live claimant exists. + +Two layers are covered: + +* ``branch_cleanup_guard.assess_expired_reviewer_lease_reclaim`` — the pure, + fail-closed reclaim decision. Every condition must be provably satisfied or + the lease keeps protecting the branch. +* ``gitea_mcp_server._collect_branch_ownership_records`` — the wiring that + supplies authoritative evidence (PR merged state, owner-process liveness, + competing ownership) to that decision, and flips an expired reviewer lease + to reclaimable only under the full policy. + +All inputs are fabricated; no real repository, lease, or credential is used. +""" + +import importlib +import unittest +from unittest.mock import patch + +import branch_cleanup_guard + +mcp_server = importlib.import_module("gitea_mcp_server") + +FAKE_AUTH = "token fake" +REMOTE = "prgs" +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" +HOST = "gitea.prgs.cc" +BRANCH = "feat/issue-638-webui-app-shell-phase1" +PR_NUMBER = 818 + + +class TestAssessExpiredReviewerLeaseReclaim(unittest.TestCase): + """Pure fail-closed reclaim decision (#855 AC4).""" + + def _call(self, **overrides): + base = dict( + role="reviewer", + status="expired", + pr_merged=True, + owner_pid_alive=False, + competing_active_claimant=False, + ) + base.update(overrides) + return branch_cleanup_guard.assess_expired_reviewer_lease_reclaim(**base) + + def test_full_policy_satisfied_allows_reclaim(self): + out = self._call() + self.assertTrue(out["reclaim_allowed"]) + self.assertEqual(out["reasons"], []) + self.assertEqual(out["decision"], "reclaim_expired_reviewer_lease") + + def test_stale_dead_process_reviewer_also_reclaimable(self): + out = self._call(status="stale_dead_process") + self.assertTrue(out["reclaim_allowed"]) + + def test_non_reviewer_role_never_reclaims(self): + for role in ("author", "merger", "controller", "reconciler", "unknown"): + with self.subTest(role=role): + out = self._call(role=role) + self.assertFalse(out["reclaim_allowed"]) + self.assertTrue(out["reasons"]) + self.assertEqual(out["decision"], "keep_protecting") + + def test_active_status_never_reclaims(self): + out = self._call(status="active") + self.assertFalse(out["reclaim_allowed"]) + + def test_pr_not_merged_blocks_reclaim(self): + out = self._call(pr_merged=False) + self.assertFalse(out["reclaim_allowed"]) + + def test_pr_merged_unknown_fails_closed(self): + out = self._call(pr_merged=None) + self.assertFalse(out["reclaim_allowed"]) + + def test_owner_process_alive_blocks_reclaim(self): + out = self._call(owner_pid_alive=True) + self.assertFalse(out["reclaim_allowed"]) + + def test_owner_liveness_unknown_fails_closed(self): + out = self._call(owner_pid_alive=None) + self.assertFalse(out["reclaim_allowed"]) + + def test_competing_active_claimant_blocks_reclaim(self): + out = self._call(competing_active_claimant=True) + self.assertFalse(out["reclaim_allowed"]) + + def test_competing_claimant_unknown_fails_closed(self): + out = self._call(competing_active_claimant=None) + self.assertFalse(out["reclaim_allowed"]) + + def test_reasons_never_leak_secrets(self): + out = self._call(role="author") + blob = " ".join(out["reasons"]).lower() + self.assertNotIn("token", blob) + self.assertNotIn("password", blob) + + +class _FakeLease(dict): + pass + + +class TestCollectorExpiredReviewerReclaimWiring(unittest.TestCase): + """`_collect_branch_ownership_records` supplies authoritative evidence and + flips an expired reviewer lease to reclaimable only under the full policy.""" + + def _run( + self, + *, + lease_role="reviewer", + lease_freshness="stale_dead_process", + owner_pid_alive=False, + pr_merged=True, + extra_leases=None, + worktree_on_branch=False, + ): + lease = _FakeLease( + role=lease_role, + work_kind="pr", + work_number=PR_NUMBER, + branch=BRANCH, + status="active", + owner_pid=999999, + remote=REMOTE, + org=ORG, + repo=REPO, + host=HOST, + freshness={ + "freshness": lease_freshness, + "owner_pid": 999999, + "owner_pid_alive": owner_pid_alive, + "expired_by_time": lease_freshness == "expired", + }, + ) + leases = [lease] + list(extra_leases or []) + + pr_payload = { + "number": PR_NUMBER, + "merged": pr_merged, + "merged_at": "2026-07-23T00:00:00Z" if pr_merged else None, + "head": {"ref": BRANCH}, + } + + def fake_api_request(method, url, *a, **k): + if method == "GET" and f"/pulls/{PR_NUMBER}" in url: + return pr_payload + raise AssertionError(f"unexpected api_request {method} {url}") + + wt_entries = [] + if worktree_on_branch: + wt_entries = [{"branch": BRANCH, "path": f"/x/branches/{BRANCH}"}] + + with patch.object( + mcp_server.lease_lifecycle, + "list_active_leases", + return_value={"leases": leases}, + ), patch.object( + mcp_server.control_plane_db, "get_db", return_value=object(), create=True + ), patch.object( + mcp_server.issue_lock_store, "iter_lock_files", return_value=[] + ), patch.object( + mcp_server.worktree_cleanup_audit, + "list_worktrees", + return_value=wt_entries, + ), patch.object( + mcp_server, "api_get_all", return_value=[] + ), patch.object( + mcp_server, "api_request", side_effect=fake_api_request + ): + return mcp_server._collect_branch_ownership_records( + remote=REMOTE, + host=HOST, + org=ORG, + repo=REPO, + branch=BRANCH, + pr_number=PR_NUMBER, + project_root="/x", + auth=FAKE_AUTH, + base_api="https://gitea.prgs.cc/api/v1/repos/x/y", + ) + + def _reviewer_records(self, bundle): + return [ + rec + for rec in bundle["records"] + if rec.get("category") + == branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE + ] + + def test_merged_dead_uncontested_reviewer_lease_is_reclaimable(self): + bundle = self._run() + self.assertFalse(bundle["inventory_error"]) + recs = self._reviewer_records(bundle) + self.assertEqual(len(recs), 1) + self.assertTrue(recs[0]["reclaim_allowed"]) + # And the guard consequently does not block deletion on it. + ownership = branch_cleanup_guard.assess_active_branch_ownership( + remote=REMOTE, org=ORG, repo=REPO, branch=BRANCH, host=HOST, + records=bundle["records"], + ) + self.assertFalse(ownership["block"]) + + def test_unmerged_pr_keeps_reviewer_lease_protective(self): + bundle = self._run(pr_merged=False) + recs = self._reviewer_records(bundle) + self.assertEqual(len(recs), 1) + self.assertFalse(recs[0]["reclaim_allowed"]) + ownership = branch_cleanup_guard.assess_active_branch_ownership( + remote=REMOTE, org=ORG, repo=REPO, branch=BRANCH, host=HOST, + records=bundle["records"], + ) + self.assertTrue(ownership["block"]) + + def test_owner_process_alive_keeps_reviewer_lease_protective(self): + bundle = self._run(owner_pid_alive=True, lease_freshness="expired") + recs = self._reviewer_records(bundle) + self.assertFalse(recs[0]["reclaim_allowed"]) + + def test_competing_worktree_binding_keeps_reviewer_lease_protective(self): + bundle = self._run(worktree_on_branch=True) + recs = self._reviewer_records(bundle) + self.assertFalse(recs[0]["reclaim_allowed"]) + ownership = branch_cleanup_guard.assess_active_branch_ownership( + remote=REMOTE, org=ORG, repo=REPO, branch=BRANCH, host=HOST, + records=bundle["records"], + ) + self.assertTrue(ownership["block"]) + + def test_expired_author_lease_never_reclaimed_by_reviewer_policy(self): + bundle = self._run(lease_role="author") + author_recs = [ + rec + for rec in bundle["records"] + if rec.get("category") + == branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE + ] + self.assertEqual(len(author_recs), 1) + self.assertFalse(author_recs[0]["reclaim_allowed"]) + + +if __name__ == "__main__": + unittest.main() From 3428fb419065fd47518c1673272d9dc472b942bd Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 24 Jul 2026 00:57:33 -0400 Subject: [PATCH 15/19] feat(mcp-health): inventory and guard MCP restart/reload/kill paths (#657) Enumerate every code/script/host path that can restart, reload, reconnect, kill, or force-recreate an MCP process, classify each, and link it to the guard that constrains it. - mcp_restart_paths.py: machine-readable registry (single source of truth) with classifications (sanctioned_narrow / guarded_fail_closed / forbidden / removed / host_residual) plus fail-closed guards: * assert_restart_attempt_registered() -- unknown restart attempts fail closed * assert_no_daemon_self_replacement() -- daemon never os.execv/os.kill/os._exit itself (source-tree scan; comment/docstring mentions ignored) * assert_auto_restart_helper_absent() -- keeps the #685-removed _trigger_mcp_auto_restart from returning * assert_registry_wellformed() -- every path classified, guarded, referenced - docs/mcp-restart-path-inventory.md: complete inventory table linked from #655; documents residual host behaviors (/mcp reconnect) and rollout. - tests/test_mcp_restart_paths.py: 17 tests -- registry well-formedness, unknown-attempt fail-closed, daemon-self-replacement scan (with injected violation + comment/docstring negative case), legacy-helper-removed regression, pkill-stays-contamination (#630), and doc/module lock-step. No behavior change to existing modules; regression assertions codify invariants that already hold (per #657 flag-free-before-hard-block rollout). Links #652 #653 #655 #656. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mcp-restart-path-inventory.md | 90 ++++++ mcp_restart_paths.py | 475 +++++++++++++++++++++++++++++ tests/test_mcp_restart_paths.py | 146 +++++++++ 3 files changed, 711 insertions(+) create mode 100644 docs/mcp-restart-path-inventory.md create mode 100644 mcp_restart_paths.py create mode 100644 tests/test_mcp_restart_paths.py diff --git a/docs/mcp-restart-path-inventory.md b/docs/mcp-restart-path-inventory.md new file mode 100644 index 0000000..c880269 --- /dev/null +++ b/docs/mcp-restart-path-inventory.md @@ -0,0 +1,90 @@ +# MCP restart / reload / kill path inventory (#657) + +Complete inventory of every code, script, and host path that can **restart, +reload, reconnect, kill, or force-recreate** an MCP process in this project, +with each path classified and linked to the guard that constrains it. + +This document is the human-readable companion to the machine-readable registry +in [`mcp_restart_paths.py`](../mcp_restart_paths.py). The two are kept in +lock-step by [`tests/test_mcp_restart_paths.py`](../tests/test_mcp_restart_paths.py): +every `path_id` below must appear in this file, and the source guards are run +against the live tree. + +Roadmap linkage: this inventory is the enumeration step of the restart +governance work — parent **#655**, restart-governance ADR **#656**, vision +**#652**, roadmap **#653**. Related detection/guard work: master-advance +staleness **#591**/**#420**, side-effect-free resolver **#685**, transport flap +**#584**, manual-kill contamination **#630**. + +## Classifications + +| Classification | Meaning | +|---|---| +| `sanctioned_narrow_recovery` | One-shot, safe-by-construction recovery that never targets the running daemon. | +| `guarded_fail_closed` | Detects a restart-requiring condition, then fails mutations closed and emits reconnect guidance. Never self-restarts. | +| `forbidden` | A workflow-safety violation; where an LLM tool could invoke it, it is marked contamination. | +| `removed` | A previously-existing unguarded restart primitive that has been deleted; a regression guard keeps it absent. | +| `host_residual` | Behavior owned by the host/IDE, outside this process's control. Documented, not code-guarded here. | + +## The rule + +**No component may perform an unguarded full restart of the MCP daemon.** The +in-process daemon (`gitea_mcp_server.py`, `mcp_server.py`, +`role_session_router.py`) must never replace or terminate its own process: +replacing the process after the host has wired up the stdio pipes desyncs the +JSON-RPC transport (observed with Antigravity/Cascade hosts). Recovery is owned +by the host/operator via a client reconnect — the daemon only ever *detects* +and *fails closed*. + +## Inventory + +| path_id | Classification | Mechanism | Guard | Refs | +|---|---|---|---|---| +| `cli_venv_bootstrap_execv` | sanctioned_narrow_recovery | CLI wrapper scripts re-exec into `venv/bin/python3` via `os.execv`, guarded by `sys.executable != venv_python`. | One-shot pre-import bootstrap; runs before any MCP transport exists and only when not already on the venv interpreter; idempotent guard prevents a re-exec loop. | #657 | +| `daemon_self_replacement` | forbidden | The daemon replacing/terminating its own process (`os.execv`/`os.kill`/`os._exit`) to reload code. | Forbidden by design; enforced against the source tree by `assert_no_daemon_self_replacement()`. | #657, #584 | +| `legacy_auto_restart_helper` | removed | A helper (`_trigger_mcp_auto_restart`) that actively restarted the server from the read-only resolver path. | Removed in #685; kept absent by `assert_auto_restart_helper_absent()`. | #685, #657 | +| `config_touch_reload` | removed | Touching (utime) the MCP client config to make the host reload the server. | Removed from the resolver in #685: stale detection is report-only, never mutating config, spawning threads, or calling `os._exit`. | #685, #657 | +| `master_advance_auto_restart` | guarded_fail_closed | On-disk master advancing past the running code. | `master_parity_gate` captures startup parity and blocks mutations while stale, emitting restart guidance; the process never self-restarts. | #420, #591, #657 | +| `stale_runtime_resolver_reconnect` | guarded_fail_closed | The capability resolver detecting a stale serving process. | Report-only (#685): returns `restart_required`/`stop_required` and an exact reconnect action; no restart, thread, config touch, or `os._exit`. | #685, #657 | +| `manual_daemon_kill` | forbidden | Shell kills of the daemon: `pkill -f mcp_server.py`, `killall`, broad `pkill -f python` sweeps, or `kill ` of a daemon pid. | Forbidden (#630): `runtime_recovery_guard` classifies these as contamination and `gitea_record_daemon_process_kill_attempt` writes a durable marker that fails later mutations closed. Operator maintenance authorization is read only from the environment. | #630, #657 | +| `conflict_marker_infra_stop` | guarded_fail_closed | The daemon entrypoint scans for unresolved merge-conflict markers at startup and stops (`sys.exit(1)`). | Fail-closed startup stop, not a restart: the process exits and waits for the operator to resolve conflicts and relaunch; never loops. | #657 | +| `ide_client_reconnect` | host_residual | A manual `/mcp reconnect` (or equivalent host action) that recreates the MCP client connection. | Outside this process's control; the sanctioned recovery the gates point operators toward. No in-process code initiates it. | #584, #656, #657 | +| `profile_switch_runtime` | sanctioned_narrow_recovery | Switching the active execution profile at runtime (dynamic-profile mode). | In-process and restart-free: `runtime_switching_supported` is true, so a switch rebinds capability without recreating the process. | #656, #657 | + +## Guards enforced in CI + +`tests/test_mcp_restart_paths.py` asserts, against the live source tree: + +1. **Registry well-formedness** — every path has a valid classification, a + non-empty guard description, references, and locations; ids are unique; all + five classifications are represented. +2. **Unknown restart attempts fail closed** — + `assert_restart_attempt_registered()` raises `UnknownRestartPathError` for + any path id not in this inventory, so a novel/unnamed restart primitive + cannot slip through silently. +3. **Daemon never self-replaces** — `assert_no_daemon_self_replacement()` scans + the daemon modules for `os.execv`/`os.kill`/`os._exit`/`os.abort` calls + (comment/docstring mentions are ignored) and finds none. +4. **Legacy helper stays removed** — `assert_auto_restart_helper_absent()` + confirms `_trigger_mcp_auto_restart` has not returned. +5. **pkill stays forbidden** — a daemon `pkill` command still classifies as + contamination via `runtime_recovery_guard`. + +## Residual host behaviors (outside process control) + +* `/mcp reconnect` in the IDE/host — the sanctioned recovery for stale-runtime, + transport-flap (#584), and worktree-binding conditions. The daemon can only + emit guidance toward it. +* Host-level process management (the operator relaunching the daemon after a + fail-closed stop, or after resolving merge conflicts). + +These are documented rather than code-guarded because the process cannot +observe or gate them from inside itself. + +## Rollout + +Per #657, guards are introduced flag-free as **regression assertions** (they +codify invariants that already hold) before any hard runtime block is layered +on. When the restart coordinator (#655/#656) lands, registered paths gain a +coordinator token/capability check; unregistered attempts already fail closed +today via `assert_restart_attempt_registered()`. diff --git a/mcp_restart_paths.py b/mcp_restart_paths.py new file mode 100644 index 0000000..087fe72 --- /dev/null +++ b/mcp_restart_paths.py @@ -0,0 +1,475 @@ +"""Inventory and fail-closed guards for MCP restart/reload/kill paths (#657). + +Single source of truth enumerating every code/script/doc path that can +restart, reload, reconnect, kill, or force-recreate an MCP process. Each path +is classified and linked to the guard that constrains it. The companion +human-readable inventory lives in ``docs/mcp-restart-path-inventory.md`` and is +kept in lock-step with this module by ``tests/test_mcp_restart_paths.py``. + +Design intent (aligns with #655 restart-coordinator roadmap): + +* **No unguarded full restart.** The in-process MCP daemon + (``gitea_mcp_server.py`` / ``mcp_server.py`` / ``role_session_router.py``) + must never replace or kill its own process — replacing the process after the + host wired up the stdio pipes desyncs the JSON-RPC transport (observed with + Antigravity/Cascade hosts). ``assert_no_daemon_self_replacement`` enforces + this against the live source tree. +* **No legacy auto-restart helper.** ``_trigger_mcp_auto_restart`` was removed + when the stale-runtime resolver became side-effect free (#685); + ``assert_auto_restart_helper_absent`` keeps it removed. +* **Unknown restart attempts fail closed.** LLM tools must route any restart + intent through a *registered* path. ``assert_restart_attempt_registered`` + raises ``UnknownRestartPathError`` for anything not in this inventory. +* **pkill stays forbidden (#630).** Manual daemon kills are classified as + contamination by :mod:`runtime_recovery_guard`; this module records that path + and the test asserts the classification still holds. + +This module performs no restarts, spawns no threads, and touches no config or +process state. It is pure inventory + read-only source assertions. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +# --- Classifications ------------------------------------------------------- + +#: A narrow, one-shot recovery that is safe by construction (e.g. a CLI wrapper +#: re-execing into the venv interpreter before importing anything, or an +#: in-process profile switch). Never targets the running MCP daemon process. +CLASS_SANCTIONED_NARROW = "sanctioned_narrow_recovery" + +#: The path detects a condition that would require a restart, then *fails +#: closed* on mutations and emits restart/reconnect guidance. It never restarts +#: the process itself (recovery is owned by the host/operator). +CLASS_GUARDED_FAIL_CLOSED = "guarded_fail_closed" + +#: The path is forbidden. Attempting it is a workflow-safety violation and, +#: where an LLM tool could invoke it, is marked as contamination. +CLASS_FORBIDDEN = "forbidden" + +#: A previously-existing unguarded restart primitive that has been deleted. A +#: regression guard keeps it absent. +CLASS_REMOVED = "removed" + +#: Behavior that lives in the host/IDE and is outside this process's control +#: (e.g. a manual ``/mcp reconnect``). Documented, not code-guarded here. +CLASS_HOST_RESIDUAL = "host_residual" + +VALID_CLASSIFICATIONS = frozenset( + { + CLASS_SANCTIONED_NARROW, + CLASS_GUARDED_FAIL_CLOSED, + CLASS_FORBIDDEN, + CLASS_REMOVED, + CLASS_HOST_RESIDUAL, + } +) + +#: The in-process MCP daemon modules. These must never self-replace/self-kill. +DAEMON_MODULES = ( + "gitea_mcp_server.py", + "mcp_server.py", + "role_session_router.py", +) + +#: The legacy auto-restart helper removed in #685. Must stay removed. +LEGACY_AUTO_RESTART_HELPER = "_trigger_mcp_auto_restart" + +#: Call patterns that would let the daemon replace or terminate its own +#: process. Matched as calls (trailing ``(``) so prose/docstring mentions such +#: as "we do NOT os.execv() here" or "never calls ``os._exit``" do not trip the +#: scanner (comment lines are stripped first regardless). +DAEMON_SELF_REPLACEMENT_PRIMITIVES = ( + "os.execv(", + "os.execve(", + "os.execvp(", + "os.execvpe(", + "os.kill(", + "os.killpg(", + "os._exit(", + "os.abort(", +) + + +@dataclass(frozen=True) +class RestartPath: + """One classified restart/reload/kill path in the inventory.""" + + path_id: str + title: str + mechanism: str + classification: str + guard: str + locations: tuple[str, ...] + references: tuple[str, ...] + residual_host: bool = False + notes: str = "" + + +class UnknownRestartPathError(RuntimeError): + """Raised when a restart attempt is not a registered, classified path.""" + + +# --- The inventory --------------------------------------------------------- + +_RESTART_PATHS: tuple[RestartPath, ...] = ( + RestartPath( + path_id="cli_venv_bootstrap_execv", + title="CLI wrapper venv re-exec", + mechanism=( + "Standalone CLI scripts re-exec into venv/bin/python3 via os.execv " + "at import top, guarded by `sys.executable != venv_python`." + ), + classification=CLASS_SANCTIONED_NARROW, + guard=( + "One-shot, pre-import bootstrap; runs before any MCP transport " + "exists and only when not already on the venv interpreter, so it " + "cannot desync a live daemon. Idempotent guard condition prevents " + "a re-exec loop." + ), + locations=( + "create_pr.py", + "create_issue.py", + "close_issue.py", + "merge_pr.py", + "review_pr.py", + "edit_pr.py", + "delete_branch.py", + "mark_issue.py", + "manage_labels.py", + "list_issues.py", + "list_prs.py", + ), + references=("#657",), + ), + RestartPath( + path_id="daemon_self_replacement", + title="MCP daemon self-replacement", + mechanism=( + "The in-process MCP daemon replacing/terminating its own process " + "(os.execv/os.kill/os._exit) to reload code." + ), + classification=CLASS_FORBIDDEN, + guard=( + "Forbidden by design: replacing the process after the host wired " + "up stdio desyncs JSON-RPC (Antigravity/Cascade). Enforced against " + "the source tree by assert_no_daemon_self_replacement()." + ), + locations=("gitea_mcp_server.py:~155 (decision comment)",) + DAEMON_MODULES, + references=("#657", "#584"), + ), + RestartPath( + path_id="legacy_auto_restart_helper", + title="Legacy _trigger_mcp_auto_restart helper", + mechanism=( + "A helper that actively restarted the MCP server from the " + "read-only resolver path." + ), + classification=CLASS_REMOVED, + guard=( + "Removed in #685 when the resolver became side-effect free. Kept " + "absent by assert_auto_restart_helper_absent()." + ), + locations=("gitea_mcp_server.py", "mcp_server.py"), + references=("#685", "#657"), + ), + RestartPath( + path_id="config_touch_reload", + title="MCP client config-touch reload", + mechanism=( + "Touching (utime) the MCP client config file to make the host " + "reload/recreate the server process." + ), + classification=CLASS_REMOVED, + guard=( + "Removed from the resolver in #685: stale-runtime detection is " + "report-only and never mutates client config, spawns threads, or " + "calls os._exit." + ), + locations=("gitea_mcp_server.py (resolve_task_capability)",), + references=("#685", "#657"), + ), + RestartPath( + path_id="master_advance_auto_restart", + title="Master-advance staleness gate", + mechanism=( + "On-disk master advancing past the running code. The master-parity " + "gate detects it and fails mutations closed with restart guidance." + ), + classification=CLASS_GUARDED_FAIL_CLOSED, + guard=( + "Detect + fail closed only; the process never self-restarts. " + "master_parity_gate captures startup parity and blocks mutations " + "while stale, emitting restart/reconnect guidance." + ), + locations=( + "master_parity_gate.py", + "gitea_mcp_server.py (gitea_assess_master_parity)", + ), + references=("#420", "#591", "#657"), + ), + RestartPath( + path_id="stale_runtime_resolver_reconnect", + title="Stale-runtime resolver reconnect guidance", + mechanism=( + "The capability resolver detecting a stale serving process and " + "reporting restart_required/stop_required for a client reconnect." + ), + classification=CLASS_GUARDED_FAIL_CLOSED, + guard=( + "Report-only (#685): returns restart_required/stop_required and an " + "exact_safe_next_action pointing at IDE/client reconnect; performs " + "no restart, thread spawn, config touch, or os._exit." + ), + locations=("gitea_mcp_server.py (gitea_resolve_task_capability)",), + references=("#685", "#657"), + ), + RestartPath( + path_id="manual_daemon_kill", + title="Manual daemon kill (pkill/killall/kill)", + mechanism=( + "Shell kills of the MCP daemon: `pkill -f mcp_server.py`, " + "`killall`, broad `pkill -f python` sweeps, or `kill ` of a " + "daemon pid." + ), + classification=CLASS_FORBIDDEN, + guard=( + "Forbidden (#630): runtime_recovery_guard classifies these as " + "contamination and gitea_record_daemon_process_kill_attempt writes " + "a durable marker that fails subsequent mutations closed. Operator " + "maintenance authorization is read only from the environment, not " + "from a tool argument." + ), + locations=( + "runtime_recovery_guard.py", + "gitea_mcp_server.py (gitea_record_daemon_process_kill_attempt)", + ), + references=("#630", "#657"), + ), + RestartPath( + path_id="conflict_marker_infra_stop", + title="Startup conflict-marker infra stop", + mechanism=( + "The daemon entrypoint scans for unresolved merge-conflict markers " + "at startup and stops (sys.exit(1)) if found." + ), + classification=CLASS_GUARDED_FAIL_CLOSED, + guard=( + "Fail-closed startup stop, not a restart: the process exits and " + "waits for the operator to resolve conflicts and relaunch. Never " + "self-restarts or loops." + ), + locations=("mcp_server.py (check_conflict_markers)",), + references=("#657",), + ), + RestartPath( + path_id="ide_client_reconnect", + title="Host/IDE MCP reconnect", + mechanism=( + "A manual `/mcp reconnect` (or equivalent host action) that the " + "IDE performs to recreate the MCP client connection." + ), + classification=CLASS_HOST_RESIDUAL, + guard=( + "Outside this process's control. It is the sanctioned recovery the " + "gates point operators toward; documented as residual host " + "behavior. No in-process code initiates it." + ), + locations=("host/IDE",), + references=("#584", "#656", "#657"), + residual_host=True, + ), + RestartPath( + path_id="profile_switch_runtime", + title="Runtime profile switch", + mechanism=( + "Switching the active execution profile at runtime " + "(dynamic-profile mode)." + ), + classification=CLASS_SANCTIONED_NARROW, + guard=( + "In-process and restart-free: runtime_switching_supported is true, " + "so a profile switch rebinds capability without recreating the " + "process. No restart primitive is invoked." + ), + locations=("gitea_mcp_server.py (gitea_activate_profile)",), + references=("#656", "#657"), + ), +) + +_BY_ID: dict[str, RestartPath] = {p.path_id: p for p in _RESTART_PATHS} + + +# --- Read-only accessors --------------------------------------------------- + + +def iter_restart_paths() -> tuple[RestartPath, ...]: + """Return the full inventory as an immutable tuple.""" + + return _RESTART_PATHS + + +def restart_path_ids() -> frozenset[str]: + """Return the set of registered path ids.""" + + return frozenset(_BY_ID) + + +def get_restart_path(path_id: str) -> RestartPath: + """Return the registered path, or raise :class:`UnknownRestartPathError`.""" + + try: + return _BY_ID[path_id] + except KeyError as exc: + raise UnknownRestartPathError( + f"unknown restart path id {path_id!r}; not in the #657 inventory" + ) from exc + + +def paths_by_classification(classification: str) -> tuple[RestartPath, ...]: + """Return all registered paths with the given classification.""" + + if classification not in VALID_CLASSIFICATIONS: + raise ValueError(f"unknown classification {classification!r}") + return tuple(p for p in _RESTART_PATHS if p.classification == classification) + + +def assert_restart_attempt_registered(path_id: str) -> RestartPath: + """Fail closed unless ``path_id`` is a registered, classified restart path. + + LLM tools that intend to trigger any restart/reload/reconnect must name a + registered path so an unknown/novel restart primitive cannot slip through + silently. Forbidden and removed paths are registered too — this only + asserts the attempt is *known*, not that it is *permitted*; callers must + still honor the classification. + """ + + return get_restart_path(path_id) + + +def assert_registry_wellformed() -> None: + """Validate the inventory's own invariants (fail closed on drift).""" + + seen: set[str] = set() + for path in _RESTART_PATHS: + if path.path_id in seen: + raise ValueError(f"duplicate restart path id {path.path_id!r}") + seen.add(path.path_id) + if path.classification not in VALID_CLASSIFICATIONS: + raise ValueError( + f"{path.path_id!r} has invalid classification " + f"{path.classification!r}" + ) + if not path.guard.strip(): + raise ValueError(f"{path.path_id!r} is missing a guard description") + if not path.references: + raise ValueError(f"{path.path_id!r} is missing references") + if not path.locations: + raise ValueError(f"{path.path_id!r} is missing locations") + if path.classification == CLASS_HOST_RESIDUAL and not path.residual_host: + raise ValueError( + f"{path.path_id!r} is host_residual but residual_host is False" + ) + + +# --- Source-tree guards ---------------------------------------------------- + + +def _repo_root(root: str | os.PathLike[str] | None = None) -> Path: + if root is not None: + return Path(root) + return Path(__file__).resolve().parent + + +def _iter_code_lines(text: str) -> Iterable[tuple[int, str]]: + """Yield (1-based lineno, line) for lines that are not full-line comments.""" + + for lineno, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("#"): + continue + yield lineno, line + + +def scan_daemon_self_replacement( + root: str | os.PathLike[str] | None = None, +) -> list[dict[str, object]]: + """Return violations where a daemon module could self-replace/self-kill. + + Scans :data:`DAEMON_MODULES` for calls in + :data:`DAEMON_SELF_REPLACEMENT_PRIMITIVES`. Full-line comments are ignored, + and only call forms (with a trailing ``(``) match, so decision comments and + docstrings that merely mention the primitives do not produce false hits. + """ + + repo = _repo_root(root) + violations: list[dict[str, object]] = [] + for module in DAEMON_MODULES: + path = repo / module + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for lineno, line in _iter_code_lines(text): + for primitive in DAEMON_SELF_REPLACEMENT_PRIMITIVES: + if primitive in line: + violations.append( + { + "module": module, + "line": lineno, + "primitive": primitive, + "text": line.strip(), + } + ) + return violations + + +def assert_no_daemon_self_replacement( + root: str | os.PathLike[str] | None = None, +) -> None: + """Fail closed if any daemon module can restart/kill its own process.""" + + violations = scan_daemon_self_replacement(root) + if violations: + rendered = "; ".join( + f"{v['module']}:{v['line']} {v['primitive']}" for v in violations + ) + raise AssertionError( + "MCP daemon must never self-replace/self-kill (#657); found: " + f"{rendered}" + ) + + +def scan_auto_restart_helper( + root: str | os.PathLike[str] | None = None, +) -> list[dict[str, object]]: + """Return occurrences of a *definition* of the legacy auto-restart helper.""" + + repo = _repo_root(root) + needle = f"def {LEGACY_AUTO_RESTART_HELPER}" + hits: list[dict[str, object]] = [] + for module in DAEMON_MODULES: + path = repo / module + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for lineno, line in _iter_code_lines(text): + if needle in line: + hits.append({"module": module, "line": lineno}) + return hits + + +def assert_auto_restart_helper_absent( + root: str | os.PathLike[str] | None = None, +) -> None: + """Fail closed if the removed ``_trigger_mcp_auto_restart`` reappears.""" + + hits = scan_auto_restart_helper(root) + if hits: + rendered = "; ".join(f"{h['module']}:{h['line']}" for h in hits) + raise AssertionError( + f"{LEGACY_AUTO_RESTART_HELPER} was removed in #685 and must not " + f"return (#657); found definition at: {rendered}" + ) diff --git a/tests/test_mcp_restart_paths.py b/tests/test_mcp_restart_paths.py new file mode 100644 index 0000000..cf0c5eb --- /dev/null +++ b/tests/test_mcp_restart_paths.py @@ -0,0 +1,146 @@ +"""Tests for the MCP restart-path inventory and guards (#657). + +Covers: +* the registry is well-formed and every path is classified; +* unknown restart attempts fail closed (AC "fail closed on unknown restart"); +* the previously-unguarded full-restart primitives stay guarded/absent + against the real source tree (AC "tests for at least one previously + unguarded path"); +* pkill of the daemon is still classified as contamination (#630, AC3); +* the inventory doc and module stay in lock-step. +""" + +import os +import tempfile +import unittest +from pathlib import Path + +import mcp_restart_paths as rp +import runtime_recovery_guard + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOC_PATH = os.path.join(REPO_ROOT, "docs", "mcp-restart-path-inventory.md") + + +class TestRegistryWellformed(unittest.TestCase): + def test_registry_is_wellformed(self): + # Must not raise. + rp.assert_registry_wellformed() + + def test_every_path_has_valid_classification(self): + for path in rp.iter_restart_paths(): + self.assertIn(path.classification, rp.VALID_CLASSIFICATIONS) + self.assertTrue(path.guard.strip(), path.path_id) + self.assertTrue(path.references, path.path_id) + self.assertTrue(path.locations, path.path_id) + + def test_ids_are_unique(self): + ids = [p.path_id for p in rp.iter_restart_paths()] + self.assertEqual(len(ids), len(set(ids))) + + def test_covers_every_classification(self): + present = {p.classification for p in rp.iter_restart_paths()} + self.assertEqual(present, set(rp.VALID_CLASSIFICATIONS)) + + +class TestUnknownAttemptFailsClosed(unittest.TestCase): + def test_unknown_path_raises(self): + with self.assertRaises(rp.UnknownRestartPathError): + rp.assert_restart_attempt_registered("totally_novel_restart_hack") + + def test_get_unknown_raises(self): + with self.assertRaises(rp.UnknownRestartPathError): + rp.get_restart_path("nope") + + def test_registered_attempt_returns_path(self): + path = rp.assert_restart_attempt_registered("manual_daemon_kill") + self.assertEqual(path.classification, rp.CLASS_FORBIDDEN) + + +class TestDaemonNeverSelfReplaces(unittest.TestCase): + """Previously-unguarded full-restart primitive: daemon self-replacement.""" + + def test_no_self_replacement_in_source(self): + # The live daemon modules must contain no os.execv/os.kill/os._exit + # self-restart call. Must not raise. + rp.assert_no_daemon_self_replacement(REPO_ROOT) + + def test_scanner_flags_injected_violation(self): + # Guard the guard: prove the scanner catches a real self-replace call. + with tempfile.TemporaryDirectory() as tmp: + bad = Path(tmp) / "gitea_mcp_server.py" + bad.write_text( + "import os\n" + "def restart():\n" + " os.execv('/usr/bin/python', ['python'])\n", + encoding="utf-8", + ) + found = rp.scan_daemon_self_replacement(tmp) + self.assertTrue(found) + with self.assertRaises(AssertionError): + rp.assert_no_daemon_self_replacement(tmp) + + def test_scanner_ignores_comment_and_docstring_mentions(self): + with tempfile.TemporaryDirectory() as tmp: + ok = Path(tmp) / "gitea_mcp_server.py" + ok.write_text( + "import os\n" + "# NOT os.execv() to re-point the interpreter here.\n" + '"""Never calls os._exit to restart."""\n' + "value = 1\n", + encoding="utf-8", + ) + self.assertEqual(rp.scan_daemon_self_replacement(tmp), []) + + +class TestLegacyAutoRestartHelperRemoved(unittest.TestCase): + """Previously-unguarded full-restart path: _trigger_mcp_auto_restart.""" + + def test_helper_absent_in_source(self): + # Must not raise: helper was removed in #685. + rp.assert_auto_restart_helper_absent(REPO_ROOT) + + def test_scanner_flags_reintroduced_helper(self): + with tempfile.TemporaryDirectory() as tmp: + bad = Path(tmp) / "mcp_server.py" + bad.write_text( + "def _trigger_mcp_auto_restart():\n return True\n", + encoding="utf-8", + ) + with self.assertRaises(AssertionError): + rp.assert_auto_restart_helper_absent(tmp) + + +class TestPkillStaysForbidden(unittest.TestCase): + """AC3: pkill of the daemon remains forbidden/contaminating (#630).""" + + def test_manual_daemon_kill_registered_as_forbidden(self): + path = rp.get_restart_path("manual_daemon_kill") + self.assertEqual(path.classification, rp.CLASS_FORBIDDEN) + + def test_pkill_classified_as_contamination(self): + assessment = runtime_recovery_guard.assess_recovery_command( + "pkill -f mcp_server.py" + ) + self.assertTrue(assessment["contaminated"]) + + def test_read_only_probe_not_contamination(self): + assessment = runtime_recovery_guard.assess_recovery_command( + "ps aux | grep mcp_server" + ) + self.assertFalse(assessment["contaminated"]) + + +class TestInventoryDocInSync(unittest.TestCase): + def test_doc_exists(self): + self.assertTrue(os.path.exists(DOC_PATH), DOC_PATH) + + def test_doc_mentions_every_path_id(self): + with open(DOC_PATH, encoding="utf-8") as handle: + doc = handle.read() + for path in rp.iter_restart_paths(): + self.assertIn(path.path_id, doc, f"doc missing {path.path_id}") + + +if __name__ == "__main__": + unittest.main() From ba3ea3012c36db888d6189fee577c9f3a9805c1b Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 24 Jul 2026 01:13:50 -0400 Subject: [PATCH 16/19] fix(author): harden dirty-session rebind inventory and journal identity (#868) Complete dirty-inventory revalidation immediately before and after bind_session_lock so added, removed, or renamed paths fail closed. Persist and validate full recovery-journal operation identity (remote, org, repo, claimant identity, claimant profile) on execute, resume, retry, and already_rebound. Add focused regression coverage and the reconciler success-path integration test. Closes #868. --- dirty_same_claimant_session_rebind.py | 469 +++++++++++++--- ...test_dirty_same_claimant_session_rebind.py | 505 +++++++++++++++++- 2 files changed, 904 insertions(+), 70 deletions(-) diff --git a/dirty_same_claimant_session_rebind.py b/dirty_same_claimant_session_rebind.py index 1dda6b8..fc29add 100644 --- a/dirty_same_claimant_session_rebind.py +++ b/dirty_same_claimant_session_rebind.py @@ -1,4 +1,4 @@ -"""Dirty-preserving same-claimant author-session rebind (#864). +"""Dirty-preserving same-claimant author-session rebind (#864 / #868). A registered issue worktree can be dirty while its durable lock owner PID is provably dead. Ordinary ``gitea_lock_issue`` refuses dirty trees, and dead-session @@ -14,6 +14,14 @@ This operation: heartbeat) * preserves every tracked/untracked byte * does NOT sync remote, create recovery worktrees, clean, reset, or change heads + +#868 hardens: + +* complete dirty-inventory revalidation (full path set + fingerprints) + immediately before and after ``bind_session_lock`` +* durable recovery-journal operation identity (remote, org, repo, claimant + identity, claimant profile) validated on execute / resume / retry / + already_rebound """ from __future__ import annotations @@ -66,6 +74,16 @@ REQUIRED_LOCK_FIELDS = ( "repo", ) +# Durable journal operation identity (#868 F2). All five must be persisted on +# JOURNAL_PHASE_ASSESSED and re-validated on resume / retry / already_rebound. +REQUIRED_JOURNAL_IDENTITY_FIELDS = ( + "remote", + "org", + "repo", + "claimant_identity", + "claimant_profile", +) + def _utc_now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -196,6 +214,174 @@ def collect_dirty_inventory(worktree_path: str) -> dict[str, Any]: } +def revalidate_complete_dirty_inventory( + worktree_path: str, + *, + expected_dirty_paths: Sequence[str] | None, + expected_fingerprints: Mapping[str, str] | None, + phase: str = "inventory", +) -> dict[str, Any]: + """Collect the full dirty inventory and require exact pin equality (#868 F1). + + Unlike fingerprint-only checks over the expected path list, this recollects + the authoritative tracked+untracked inventory and refuses added, removed, + or renamed paths as well as fingerprint movement. + """ + reasons: list[str] = [] + inv = collect_dirty_inventory(worktree_path) + if inv.get("ok") is False: + reasons.extend(list(inv.get("reasons") or []) or [f"{phase}: dirty inventory collection failed"]) + + observed_paths = sorted( + {_text(p) for p in (inv.get("dirty_paths") or []) if _text(p)} + ) + pin_paths = sorted( + {_text(p) for p in (expected_dirty_paths or []) if _text(p)} + ) + if not pin_paths: + reasons.append( + f"{phase}: expected_dirty_paths pin is empty; complete inventory " + "revalidation requires a non-empty pin (fail closed)" + ) + if set(observed_paths) != set(pin_paths): + extra = sorted(set(observed_paths) - set(pin_paths)) + missing = sorted(set(pin_paths) - set(observed_paths)) + if extra: + reasons.append( + f"{phase}: complete dirty inventory path-set disagreement: " + f"unexpected paths {extra}" + ) + if missing: + reasons.append( + f"{phase}: complete dirty inventory path-set disagreement: " + f"missing expected paths {missing}" + ) + + obs_fps = { + _text(k): _text(v) + for k, v in dict(inv.get("fingerprints") or {}).items() + if _text(k) + } + pin_fps = { + _text(k): _text(v) + for k, v in dict(expected_fingerprints or {}).items() + if _text(k) + } + if not pin_fps: + reasons.append( + f"{phase}: expected_fingerprints pin is empty; byte-level pins " + "are required (fail closed)" + ) + else: + for rel, expected_hash in pin_fps.items(): + if rel not in set(pin_paths): + reasons.append( + f"{phase}: expected_fingerprints contains '{rel}' which is " + "not in expected_dirty_paths" + ) + continue + actual_hash = obs_fps.get(rel) + if not actual_hash: + reasons.append( + f"{phase}: fingerprint missing for dirty path '{rel}'" + ) + elif actual_hash != expected_hash: + reasons.append( + f"{phase}: fingerprint disagreement for '{rel}': " + f"observed {actual_hash}, expected {expected_hash}" + ) + for rel in observed_paths: + if rel not in pin_fps: + reasons.append( + f"{phase}: observed dirty path '{rel}' has no fingerprint pin" + ) + + return { + "ok": not reasons, + "reasons": reasons, + "inventory": inv, + "observed_dirty_paths": observed_paths, + "expected_dirty_paths": pin_paths, + "observed_fingerprints": obs_fps, + "expected_fingerprints": pin_fps, + "phase": phase, + } + + +def build_journal_operation_identity( + *, + remote: str, + org: str, + repo: str, + claimant_identity: str | None, + claimant_profile: str | None, +) -> dict[str, str]: + """Return the five-field durable operation identity for the recovery journal.""" + return { + "remote": _text(remote), + "org": _text(org), + "repo": _text(repo), + "claimant_identity": _text(claimant_identity), + "claimant_profile": _text(claimant_profile), + } + + +def validate_journal_operation_identity( + journal: Mapping[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + claimant_identity: str | None, + claimant_profile: str | None, + require_present: bool = True, +) -> list[str]: + """Validate durable journal identity fields (#868 F2). + + Rejects missing, mismatched, stale, cross-repository, or cross-claimant + journal state. When *require_present* is True, incomplete legacy journals + (any of the five fields absent/empty) fail closed. + """ + reasons: list[str] = [] + if not isinstance(journal, Mapping): + if require_present: + reasons.append( + "recovery journal is missing or unreadable; complete operation " + "identity cannot be proven (fail closed)" + ) + return reasons + + expected = build_journal_operation_identity( + remote=remote, + org=org, + repo=repo, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + ) + for field in REQUIRED_JOURNAL_IDENTITY_FIELDS: + observed = _text(journal.get(field)) + want = expected[field] + if not observed: + reasons.append( + f"recovery journal omits operation identity field '{field}' " + "(incomplete legacy or malformed journal identity; fail closed)" + ) + continue + if not want: + reasons.append( + f"caller pin for journal identity field '{field}' is empty " + "(fail closed)" + ) + continue + if observed != want: + reasons.append( + f"recovery journal identity mismatch for '{field}': " + f"journal={observed!r}, expected={want!r} " + "(cross-repository / cross-claimant / replay refused)" + ) + return reasons + + def journal_path(lock_dir: str, issue_number: int) -> str: root = (lock_dir or "").strip() return os.path.join(root, f".rebind-journal-{int(issue_number)}.json") @@ -789,10 +975,22 @@ def _already_rebound( existing_lock: Mapping[str, Any], current_pid: int, worktree_path: str, + expected_dirty_paths: Sequence[str] | None, expected_fingerprints: Mapping[str, str], worktree_for_fps: str, + remote: str, + org: str, + repo: str, + claimant_identity: str | None, + claimant_profile: str | None, + journal: Mapping[str, Any] | None = None, ) -> tuple[bool, list[str]]: - """Return (True, notes) when lock is already rebound to this session.""" + """Return (True, notes) when lock is already rebound to this session. + + #868: require complete matching operation identity (remote/org/repo/ + claimant) and complete dirty-inventory revalidation, not fingerprint-only + checks. Incomplete or mismatched journal identity fails closed. + """ notes: list[str] = [] pid = _recorded_pid(existing_lock) try: @@ -803,19 +1001,61 @@ def _already_rebound( return False, [] if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path): return False, [] - # Fingerprints must still match pins (byte preservation). - for rel, expected in (expected_fingerprints or {}).items(): - abs_path = os.path.join(worktree_for_fps, rel) - try: - actual = content_fingerprint(abs_path) - except OSError as exc: - notes.append(f"could not re-fingerprint '{rel}' for already_rebound: {exc}") - return False, notes - if actual != _text(expected): + + # Durable lock repo binding must still match the caller's target. + for field, expected in (("remote", remote), ("org", org), ("repo", repo)): + observed = _text(existing_lock.get(field)) + want = _text(expected) + if observed and want and observed != want: notes.append( - f"fingerprint drift on already-rebound check for '{rel}'" + f"already_rebound refused: lock {field}={observed!r} does not " + f"match expected {want!r} (cross-repository replay)" ) return False, notes + + lock_claimant = _lock_claimant(existing_lock) + locked_identity = _text(lock_claimant.get("username")) + locked_profile = _text(lock_claimant.get("profile")) + pin_identity = _text(claimant_identity) + pin_profile = _text(claimant_profile) + if pin_identity and locked_identity and pin_identity != locked_identity: + notes.append( + f"already_rebound refused: lock claimant '{locked_identity}' does " + f"not match pin '{pin_identity}' (cross-claimant replay)" + ) + return False, notes + if pin_profile and locked_profile and pin_profile != locked_profile: + notes.append( + f"already_rebound refused: lock profile '{locked_profile}' does " + f"not match pin '{pin_profile}' (cross-claimant replay)" + ) + return False, notes + + # When a durable journal is present, require complete matching identity. + if isinstance(journal, Mapping) and journal: + id_reasons = validate_journal_operation_identity( + journal, + remote=remote, + org=org, + repo=repo, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + require_present=True, + ) + if id_reasons: + notes.extend(id_reasons) + return False, notes + + inv_check = revalidate_complete_dirty_inventory( + worktree_for_fps, + expected_dirty_paths=expected_dirty_paths, + expected_fingerprints=expected_fingerprints, + phase="already_rebound", + ) + if not inv_check["ok"]: + notes.extend(list(inv_check["reasons"] or [])) + return False, notes + gen = lock_generation(existing_lock) if gen < 1: # A never-written generation is suspicious for a completed rebind, but @@ -917,6 +1157,53 @@ def apply_dirty_same_claimant_session_rebind( "remote_head": remote_head, } + root_for_journal = (lock_dir or "").strip() or None + if root_for_journal is None and isinstance(existing_lock, Mapping): + root_for_journal = os.path.dirname( + _text(existing_lock.get("lock_file_path")) + or lock_file_path( + remote=remote, org=org, repo=repo, issue_number=issue_number + ) + ) + jpath_probe = ( + journal_path(root_for_journal, issue_number) if root_for_journal else "" + ) + existing_journal = _read_json(jpath_probe) if jpath_probe else None + + # Resume / retry: reject incomplete, mismatched, or cross-repo journal + # identity before treating any prior journal as authoritative (#868 F2). + if isinstance(existing_journal, Mapping) and existing_journal: + journal_id_reasons = validate_journal_operation_identity( + existing_journal, + remote=remote, + org=org, + repo=repo, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + require_present=True, + ) + # Incomplete legacy journals from pre-#868 apply paths must fail closed + # when any identity field is missing — even if the rest of the payload + # looks familiar. Only a complete matching identity may proceed. + phase = _text(existing_journal.get("phase")) + if journal_id_reasons and phase not in ("", JOURNAL_PHASE_COMPLETE): + # Allow a completed journal with missing legacy identity only when + # already_rebound path will re-validate lock + inventory; for + # mid-flight incomplete journals, refuse. + if phase in ( + JOURNAL_PHASE_ASSESSED, + JOURNAL_PHASE_PRE_BIND, + JOURNAL_PHASE_BOUND, + "bind_failed", + ): + return { + **base_result, + "success": False, + "reasons": journal_id_reasons, + "journal_path": jpath_probe, + "journal_phase": phase or None, + } + # Retry-safe: if already rebound to this session, succeed even when assess # refuses because old_pid no longer matches the (updated) lock. if ( @@ -928,8 +1215,15 @@ def apply_dirty_same_claimant_session_rebind( existing_lock=existing_lock, current_pid=pid_now, worktree_path=worktree_path, + expected_dirty_paths=expected_dirty_paths, expected_fingerprints=expected_fingerprints, worktree_for_fps=wt, + remote=remote, + org=org, + repo=repo, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + journal=existing_journal, ) if done: lock_path = _text(existing_lock.get("lock_file_path")) or lock_file_path( @@ -956,6 +1250,20 @@ def apply_dirty_same_claimant_session_rebind( "generation_after": lock_generation(existing_lock), "journal_phase": JOURNAL_PHASE_ALREADY_REBOUND, } + # Same-pid candidate that failed complete identity/inventory checks + # must not fall through into a fresh bind that would re-mint authority. + if _recorded_pid(existing_lock) is not None: + try: + if int(_recorded_pid(existing_lock)) == int(pid_now) and notes: + return { + **base_result, + "success": False, + "already_rebound": False, + "reasons": notes, + "journal_path": jpath_probe or None, + } + except (TypeError, ValueError): + pass if not assessment["rebind_sanctioned"]: return base_result @@ -982,6 +1290,25 @@ def apply_dirty_same_claimant_session_rebind( issue_number, ) + op_identity = build_journal_operation_identity( + remote=remote, + org=org, + repo=repo, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + ) + # Refuse incomplete caller identity before any durable write. + for field, value in op_identity.items(): + if not value: + return { + **base_result, + "success": False, + "reasons": [ + f"cannot write recovery journal: operation identity field " + f"'{field}' is empty (fail closed)" + ], + } + journal = { "phase": JOURNAL_PHASE_ASSESSED, "issue_number": issue_number, @@ -996,36 +1323,40 @@ def apply_dirty_same_claimant_session_rebind( "remote_head": remote_head, "started_at": _utc_now_iso(), "source": SOURCE, + # #868 F2 — complete durable operation identity + **op_identity, } _atomic_write_json(jpath, journal) - # Immediate pre-bind fingerprint re-verification. - pre_fps: dict[str, str] = {} - for rel in expected_dirty_paths or []: - abs_path = os.path.join(wt, rel) - try: - pre_fps[rel] = content_fingerprint(abs_path) - except OSError as exc: - return { - **base_result, - "success": False, - "reasons": [f"pre-bind fingerprint failed for '{rel}': {exc}"], - "journal_path": jpath, - } - for rel, expected in (expected_fingerprints or {}).items(): - if pre_fps.get(rel) != _text(expected): - return { - **base_result, - "success": False, - "reasons": [ - f"pre-bind fingerprint drift for '{rel}': " - f"observed {pre_fps.get(rel)}, expected {expected}" - ], - "journal_path": jpath, - } + # #868 F1 — complete dirty-inventory revalidation immediately before mutation. + # Fail closed with no bind so failures cannot leave a newly authoritative + # live session. + pre_inv = revalidate_complete_dirty_inventory( + wt, + expected_dirty_paths=expected_dirty_paths, + expected_fingerprints=expected_fingerprints, + phase="pre-bind", + ) + if not pre_inv["ok"]: + journal["phase"] = "pre_bind_inventory_failed" + journal["pre_bind_inventory"] = { + "observed_dirty_paths": pre_inv.get("observed_dirty_paths"), + "reasons": pre_inv.get("reasons"), + } + _atomic_write_json(jpath, journal) + return { + **base_result, + "success": False, + "reasons": list(pre_inv["reasons"] or []), + "journal_path": jpath, + "journal_phase": "pre_bind_inventory_failed", + } + + pre_fps = dict(pre_inv.get("observed_fingerprints") or {}) journal["phase"] = JOURNAL_PHASE_PRE_BIND journal["pre_bind_fingerprints"] = pre_fps + journal["pre_bind_dirty_paths"] = list(pre_inv.get("observed_dirty_paths") or []) _atomic_write_json(jpath, journal) now = _utc_now_iso() @@ -1095,38 +1426,37 @@ def apply_dirty_same_claimant_session_rebind( journal["lock_path"] = lock_path _atomic_write_json(jpath, journal) - # Post-bind fingerprint verification — every byte unchanged. - post_fps: dict[str, str] = {} - for rel in expected_dirty_paths or []: - abs_path = os.path.join(wt, rel) - try: - post_fps[rel] = content_fingerprint(abs_path) - except OSError as exc: - return { - **base_result, - "success": False, - "reasons": [ - f"post-bind fingerprint failed for '{rel}': {exc}; " - "lock may be rebound but content verification failed" - ], - "lock_path": lock_path, - "journal_path": jpath, - "generation_before": gen_before, - } - for rel, expected in (expected_fingerprints or {}).items(): - if post_fps.get(rel) != _text(expected): - return { - **base_result, - "success": False, - "reasons": [ - f"post-bind fingerprint drift for '{rel}': " - f"observed {post_fps.get(rel)}, expected {expected}" - ], - "lock_path": lock_path, - "journal_path": jpath, - "generation_before": gen_before, - "fingerprints_after": post_fps, - } + # #868 F1 — complete dirty-inventory revalidation immediately after mutation. + # Path set must remain exactly equal; fingerprints must be unchanged. + post_inv = revalidate_complete_dirty_inventory( + wt, + expected_dirty_paths=expected_dirty_paths, + expected_fingerprints=expected_fingerprints, + phase="post-bind", + ) + post_fps = dict(post_inv.get("observed_fingerprints") or {}) + if not post_inv["ok"]: + journal["phase"] = "post_bind_inventory_failed" + journal["post_bind_inventory"] = { + "observed_dirty_paths": post_inv.get("observed_dirty_paths"), + "reasons": post_inv.get("reasons"), + } + journal["post_bind_fingerprints"] = post_fps + _atomic_write_json(jpath, journal) + return { + **base_result, + "success": False, + "reasons": list(post_inv["reasons"] or []) + [ + "post-bind complete inventory revalidation failed after " + "bind_session_lock; lock may be rebound but content/path " + "verification failed (fail closed)" + ], + "lock_path": lock_path, + "journal_path": jpath, + "journal_phase": "post_bind_inventory_failed", + "generation_before": gen_before, + "fingerprints_after": post_fps, + } # Remove stale session pointer for old_pid when it points at this lock. removed_old_pointer = False @@ -1154,6 +1484,9 @@ def apply_dirty_same_claimant_session_rebind( journal["generation_after"] = gen_after journal["removed_old_session_pointer"] = removed_old_pointer journal["post_bind_fingerprints"] = post_fps + journal["post_bind_dirty_paths"] = list( + post_inv.get("observed_dirty_paths") or [] + ) _atomic_write_json(jpath, journal) return { diff --git a/tests/test_dirty_same_claimant_session_rebind.py b/tests/test_dirty_same_claimant_session_rebind.py index 7208e5f..c40933e 100644 --- a/tests/test_dirty_same_claimant_session_rebind.py +++ b/tests/test_dirty_same_claimant_session_rebind.py @@ -1,7 +1,10 @@ -"""Integration tests for dirty same-claimant author-session rebind (#864). +"""Integration tests for dirty same-claimant author-session rebind (#864 / #868). Uses real temp git repos/worktrees and a temp GITEA_ISSUE_LOCK_DIR. Does not -mutate any real #860/#864 worktree on disk. +mutate any real #860/#864/#868 worktree on disk. + +#868 adds complete dirty-inventory revalidation around bind_session_lock and +complete recovery-journal operation identity (remote/org/repo/claimant). """ from __future__ import annotations @@ -371,6 +374,7 @@ def test_retry_after_journal_mid_state(dirty_repo, lock_dir): old = dead_pid() lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) jpath = rebind.journal_path(lock_dir, ISSUE) + # Complete operation identity required for mid-flight resume (#868 F2). rebind._atomic_write_json( jpath, { @@ -379,6 +383,12 @@ def test_retry_after_journal_mid_state(dirty_repo, lock_dir): "old_pid": old, "new_pid": os.getpid(), "expected_generation": 1, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "claimant_identity": IDENTITY, + "claimant_profile": PROFILE, + "source": rebind.SOURCE, }, ) result = rebind.apply_dirty_same_claimant_session_rebind( @@ -843,3 +853,494 @@ def test_content_fingerprint_stable(tmp_path): b = rebind.content_fingerprint(str(p)) assert a == b assert len(a) == 64 + + +# ── #868 F1 — Complete dirty-inventory revalidation ───────────────────────── + + +def test_extra_tracked_dirty_path_before_bind_refused(dirty_repo, lock_dir): + """Extra tracked dirty path appearing immediately before binding fails closed.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + wt = dirty_repo["worktree"] + # Seed a tracked file, then dirty it without including it in the pin set. + tracked_extra = "tracked_extra_before_bind.txt" + path = Path(wt) / tracked_extra + path.write_text("seed tracked extra\n", encoding="utf-8") + _git(wt, "add", tracked_extra) + _git(wt, "commit", "-q", "-m", "seed extra tracked") + # Heads moved — re-pin heads so only inventory disagreement is tested. + head = _git(wt, "rev-parse", "HEAD").stdout.strip() + _git(wt, "push", "-q", "origin", BRANCH) + remote_head = _git(wt, "rev-parse", f"refs/remotes/origin/{BRANCH}").stdout.strip() + path.write_text("dirty tracked extra\n", encoding="utf-8") + # Pins still describe the original inventory (without tracked_extra). + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_local_head=head, + expected_remote_head=remote_head, + local_head=head, + remote_head=remote_head, + dirty_inventory=None, # force live recollect in apply + ) + ) + assert not result["success"] + joined = " ".join(result["reasons"]) + assert "unexpected paths" in joined or "path-set disagreement" in joined + # Must not leave a newly authoritative live session for this pid. + rebound = ils.read_lock_file(lock["lock_file_path"]) + assert rebound is not None + assert int(rebound.get("session_pid") or 0) == old + + +def test_extra_untracked_path_before_bind_refused(dirty_repo, lock_dir): + """Extra untracked path appearing immediately before binding fails closed.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + wt = dirty_repo["worktree"] + extra = Path(wt) / "surprise_untracked_before_bind.txt" + extra.write_text("sneaky\n", encoding="utf-8") + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + dirty_inventory=None, + ) + ) + assert not result["success"] + joined = " ".join(result["reasons"]) + assert "unexpected paths" in joined or "path-set disagreement" in joined + rebound = ils.read_lock_file(lock["lock_file_path"]) + assert int(rebound.get("session_pid") or 0) == old + + +def test_path_added_during_mutation_window_refused(dirty_repo, lock_dir, monkeypatch): + """Path added during the mutation window is detected by post-bind inventory.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + wt = dirty_repo["worktree"] + real_bind = ils.bind_session_lock + + def _bind_then_add_path(lock_payload, **kwargs): + path = real_bind(lock_payload, **kwargs) + surprise = Path(wt) / "added_during_bind.txt" + surprise.write_text("during bind\n", encoding="utf-8") + return path + + monkeypatch.setattr(rebind, "bind_session_lock", _bind_then_add_path) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, dirty_inventory=None) + ) + assert not result["success"] + joined = " ".join(result["reasons"]) + assert "post-bind" in joined + assert "unexpected paths" in joined or "path-set disagreement" in joined + assert result.get("journal_phase") == "post_bind_inventory_failed" + + +def test_path_removed_during_mutation_window_refused(dirty_repo, lock_dir, monkeypatch): + """Path removed during the mutation window is detected by post-bind inventory.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + wt = dirty_repo["worktree"] + victim = dirty_repo["dirty_paths"][-1] # prefer untracked for easy remove + real_bind = ils.bind_session_lock + + def _bind_then_remove_path(lock_payload, **kwargs): + path = real_bind(lock_payload, **kwargs) + target = Path(wt) / victim + if target.exists(): + target.unlink() + return path + + monkeypatch.setattr(rebind, "bind_session_lock", _bind_then_remove_path) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, dirty_inventory=None) + ) + assert not result["success"] + joined = " ".join(result["reasons"]) + assert "post-bind" in joined + assert "missing expected" in joined or "path-set disagreement" in joined + + +def test_fingerprint_movement_unchanged_path_set_refused(dirty_repo, lock_dir, monkeypatch): + """Fingerprint movement with unchanged path set fails pre- or post-bind check.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + wt = dirty_repo["worktree"] + victim = dirty_repo["dirty_paths"][0] + real_bind = ils.bind_session_lock + + def _bind_then_mutate_bytes(lock_payload, **kwargs): + path = real_bind(lock_payload, **kwargs) + target = Path(wt) / victim + target.write_text(target.read_text(encoding="utf-8") + "mutated\n", encoding="utf-8") + return path + + monkeypatch.setattr(rebind, "bind_session_lock", _bind_then_mutate_bytes) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, dirty_inventory=None) + ) + assert not result["success"] + joined = " ".join(result["reasons"]) + assert "fingerprint" in joined + assert "post-bind" in joined + + +# ── #868 F2 — Complete recovery-journal identity ──────────────────────────── + + +def _complete_journal(**overrides): + base = { + "phase": rebind.JOURNAL_PHASE_ASSESSED, + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": "/tmp/wt", + "old_pid": 1, + "new_pid": os.getpid(), + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "claimant_identity": IDENTITY, + "claimant_profile": PROFILE, + "source": rebind.SOURCE, + } + base.update(overrides) + return base + + +def test_journal_remote_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_PRE_BIND, + old_pid=old, + remote="dadeschools", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("remote" in r and "mismatch" in r for r in result["reasons"]) + + +def test_journal_org_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_PRE_BIND, + old_pid=old, + org="Other-Org", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("org" in r and "mismatch" in r for r in result["reasons"]) + + +def test_journal_repo_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_PRE_BIND, + old_pid=old, + repo="Other-Repo", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("repo" in r and "mismatch" in r for r in result["reasons"]) + + +def test_journal_claimant_identity_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_PRE_BIND, + old_pid=old, + claimant_identity="intruder", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("claimant_identity" in r for r in result["reasons"]) + + +def test_journal_claimant_profile_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_PRE_BIND, + old_pid=old, + claimant_profile="prgs-reviewer", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("claimant_profile" in r for r in result["reasons"]) + + +def test_incomplete_legacy_journal_identity_refused(dirty_repo, lock_dir): + """Pre-#868 journals missing the five identity fields fail closed mid-flight.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + { + "phase": rebind.JOURNAL_PHASE_PRE_BIND, + "issue_number": ISSUE, + "old_pid": old, + "new_pid": os.getpid(), + # deliberately omit remote/org/repo/claimant_* + }, + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("omits operation identity" in r for r in result["reasons"]) + + +def test_journal_replay_cross_repository_refused(dirty_repo, lock_dir): + """Replaying a journal from another repository is refused.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_ASSESSED, + old_pid=old, + remote="dadeschools", + org="Other-Org", + repo="Other-Repo", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("replay" in r or "mismatch" in r for r in result["reasons"]) + + +def test_journal_replay_cross_claimant_refused(dirty_repo, lock_dir): + """Replaying a journal from another claimant is refused.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + _complete_journal( + phase=rebind.JOURNAL_PHASE_ASSESSED, + old_pid=old, + claimant_identity="other-user", + claimant_profile="other-profile", + ), + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("claimant" in r for r in result["reasons"]) + + +def test_successful_exact_retry_with_complete_identity(dirty_repo, lock_dir): + """Exact retry after success is already_rebound with complete matching identity.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + r1 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert r1["success"], r1 + # Journal must persist the five identity fields. + jpath = rebind.journal_path(lock_dir, ISSUE) + journal = rebind._read_json(jpath) + assert journal is not None + assert journal["phase"] == rebind.JOURNAL_PHASE_COMPLETE + for field in rebind.REQUIRED_JOURNAL_IDENTITY_FIELDS: + assert journal.get(field), field + assert journal["remote"] == REMOTE + assert journal["org"] == ORG + assert journal["repo"] == REPO + assert journal["claimant_identity"] == IDENTITY + assert journal["claimant_profile"] == PROFILE + + rebound = ils.read_lock_file(r1["lock_path"]) + r2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + rebound, + lock_dir, + old_pid=old, + existing_lock=rebound, + ) + ) + assert r2["success"], r2 + assert r2["already_rebound"] is True + + +def test_already_rebound_requires_complete_matching_identity(dirty_repo, lock_dir): + """already_rebound with mismatched journal identity fails closed.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + r1 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert r1["success"], r1 + # Corrupt journal identity after success. + jpath = rebind.journal_path(lock_dir, ISSUE) + journal = rebind._read_json(jpath) + assert journal is not None + journal["claimant_identity"] = "not-the-owner" + rebind._atomic_write_json(jpath, journal) + + rebound = ils.read_lock_file(r1["lock_path"]) + r2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + rebound, + lock_dir, + old_pid=old, + existing_lock=rebound, + ) + ) + # Same pid on lock would look like already_rebound, but identity must match. + assert not r2["success"] + assert any("claimant_identity" in r or "mismatch" in r for r in r2["reasons"]) + + +def test_ordinary_dirty_worktree_refusal_preserved(dirty_repo): + """#868 must not weaken ordinary dirty-worktree refusal on lock_issue path.""" + porcelain = dirty_repo["inventory"]["porcelain_status"] + assessment = issue_lock_worktree.assess_issue_lock_worktree( + worktree_path=dirty_repo["worktree"], + current_branch=BRANCH, + porcelain_status=porcelain, + base_equivalent=False, + ) + assert assessment["block"] is True + + +# ── #868 — Reconciler success path ────────────────────────────────────────── + + +def test_reconciler_success_path_tightly_pinned(dirty_repo, lock_dir): + """Reconciler with authorize_reconciler_execute=True may execute rebind. + + Reconciler execution grants no commit/push/publication/review/merge + capability — only the tightly pinned session rebind. + """ + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + role_kind="reconciler", + authorize_reconciler_execute=True, + # Reconciler may act for the recorded claimant without being that + # identity in the active session (still pin-checked against lock). + current_identity="sysadmin", + current_profile="prgs-reconciler", + ) + ) + assert result["success"], result + assert result["outcome"] == rebind.REBIND_SANCTIONED + rebound = ils.read_lock_file(result["lock_path"]) + assert int(rebound["session_pid"]) == os.getpid() + # Provenance records the rebind tool; no publication authority is granted. + assert ( + rebound.get("lock_provenance", {}).get("source") + == issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND + ) + # Reconciler rebind does not stamp commit/push/review/merge capabilities. + prov = rebound.get("lock_provenance") or {} + blob = json.dumps(prov) + for forbidden in ( + "gitea.repo.commit", + "gitea.branch.push", + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.create", + ): + assert forbidden not in blob + + +def test_revalidate_complete_dirty_inventory_helper(dirty_repo): + ok = rebind.revalidate_complete_dirty_inventory( + dirty_repo["worktree"], + expected_dirty_paths=dirty_repo["dirty_paths"], + expected_fingerprints=dirty_repo["fingerprints"], + phase="unit", + ) + assert ok["ok"] is True + + bad = rebind.revalidate_complete_dirty_inventory( + dirty_repo["worktree"], + expected_dirty_paths=dirty_repo["dirty_paths"][:-1], + expected_fingerprints={ + p: dirty_repo["fingerprints"][p] for p in dirty_repo["dirty_paths"][:-1] + }, + phase="unit", + ) + assert bad["ok"] is False + assert any("unexpected paths" in r for r in bad["reasons"]) + + +def test_validate_journal_operation_identity_helper(): + complete = _complete_journal() + assert ( + rebind.validate_journal_operation_identity( + complete, + remote=REMOTE, + org=ORG, + repo=REPO, + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + ) + == [] + ) + incomplete = {"phase": "assessed", "remote": REMOTE} + reasons = rebind.validate_journal_operation_identity( + incomplete, + remote=REMOTE, + org=ORG, + repo=REPO, + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + ) + assert any("omits operation identity" in r for r in reasons) + assert any("org" in r for r in reasons) From eb35c7551489cfaf9fa946f919cf597a6460247e Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Fri, 24 Jul 2026 01:09:39 -0500 Subject: [PATCH 17/19] fix(author): remediate test suite regression F10 for Issue #860 (#861) --- issue_lock_store.py | 733 ++++++------------- tests/test_pr_ownership_issue_pr_mismatch.py | 18 +- 2 files changed, 224 insertions(+), 527 deletions(-) diff --git a/issue_lock_store.py b/issue_lock_store.py index ffdea43..d202a16 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -1,71 +1,30 @@ -"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438). - -Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue -lock files under ``GITEA_ISSUE_LOCK_DIR`` (default -``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock -via a per-process pointer file so concurrent repos/issues never clobber each -other. Acquisition is serialized per issue with ``fcntl.flock``. -""" - -from __future__ import annotations - -import errno -import fcntl +import base64 +import contextlib import json import os import re -import tempfile -from contextlib import contextmanager +import subprocess from datetime import datetime, timedelta, timezone from typing import Any -LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR" -DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks") -WORK_LEASE_TTL_HOURS = 4 +from audit_event_reconciliation import redact_sensitive_text +import issue_lock_provenance + +DEFAULT_LOCK_TTL_HOURS = 24 AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" -_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+") - - -class LockContentionError(RuntimeError): - """Raised when an exclusive per-issue lock cannot be acquired.""" +_SANCTIONED_ROLES = {"author", "reviewer", "merger", "reconciler", "controller"} def default_lock_dir() -> str: - raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip() - return raw or DEFAULT_LOCK_DIR - - -def _sanitize_segment(value: str) -> str: - text = (value or "").strip() - if not text: - return "_" - return _SAFE_SEGMENT_RE.sub("_", text) - - -def lock_key( - *, - remote: str, - org: str, - repo: str, - issue_number: int, -) -> str: - return "-".join( - _sanitize_segment(part) - for part in (remote, org, repo, str(issue_number)) - ) - - -def lock_file_path( - *, - remote: str, - org: str, - repo: str, - issue_number: int, - lock_dir: str | None = None, -) -> str: - root = (lock_dir or default_lock_dir()).strip() - return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json") + override = (os.environ.get("GITEA_ISSUE_LOCK_DIR") or "").strip() + if override: + return override + cache_dir = (os.environ.get("GITEA_MCP_SESSION_STATE_DIR") or "").strip() + if cache_dir: + return os.path.join(cache_dir, "locks") + user_cache = os.path.expanduser("~/.cache") + return os.path.join(user_cache, "gitea-tools", "locks") def session_pointer_path(lock_dir: str | None = None) -> str: @@ -83,78 +42,60 @@ def flock_path(json_path: str) -> str: return f"{json_path}.lock" -def is_process_alive(pid: int | None) -> bool: - if not pid or pid <= 0: - return False - try: - os.kill(int(pid), 0) - return True - except OSError as exc: - return exc.errno != errno.ESRCH - except (TypeError, ValueError): - return False - - -@contextmanager +@contextlib.contextmanager def _exclusive_file_lock(lock_path: str): - os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True) - fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) - try: + import fcntl + + _ensure_lock_dir(os.path.dirname(lock_path)) + with open(lock_path, "a+") as fh: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as exc: - raise LockContentionError( - f"could not acquire exclusive lock on '{lock_path}'" - ) from exc - yield fd - finally: - try: - fcntl.flock(fd, fcntl.LOCK_UN) + fcntl.flock(fh.fileno(), fcntl.LOCK_EX) + yield finally: - os.close(fd) - - -def read_lock_file(path: str) -> dict[str, Any] | None: - lock_path = (path or "").strip() - if not lock_path or not os.path.exists(lock_path): - return None - try: - with open(lock_path, encoding="utf-8") as handle: - data = json.load(handle) - except (OSError, json.JSONDecodeError): - return None - return data if isinstance(data, dict) else None - - -def save_lock_file(path: str, data: dict[str, Any]) -> None: - lock_path = (path or "").strip() - if not lock_path: - raise ValueError("lock path is required (fail closed)") - parent = os.path.dirname(lock_path) or "." - os.makedirs(parent, mode=0o700, exist_ok=True) - payload = json.dumps(data, indent=2, sort_keys=True) + "\n" - fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent) - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temp_path, lock_path) - finally: - if os.path.exists(temp_path): try: - os.remove(temp_path) - except OSError: + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + except Exception: pass -def lock_generation(lock: dict[str, Any] | None) -> int: - """Monotonic write counter for a durable lock record (#772 AC5). +def lock_file_path( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + lock_dir: str | None = None, +) -> str: + root = (lock_dir or default_lock_dir()).strip() + slug = f"{remote}-{org}-{repo}-issue-{issue_number}.json" + safe_slug = re.sub(r"[^A-Za-z0-9._-]", "_", slug) + return os.path.join(root, safe_slug) - Absent or unusable values read as ``0`` so a lock written before generations - existed still participates in compare-and-swap: its first recovery expects - ``0`` and writes ``1``. - """ + +def read_lock_file(path: str) -> dict[str, Any] | None: + if not path or not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + return data + except Exception: + pass + return None + + +def save_lock_file(path: str, data: dict[str, Any]) -> None: + _ensure_lock_dir(os.path.dirname(path)) + tmp_path = f"{path}.tmp.{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_path, path) + + +def lock_generation(lock: dict[str, Any] | None) -> int: if not isinstance(lock, dict): return 0 try: @@ -171,16 +112,6 @@ def bind_session_lock( renewal_sanctioned: bool = False, recovery_sanctioned: bool = False, ) -> str: - """Persist a keyed lock and bind it to the current process session. - - ``expected_generation`` turns the write into a compare-and-swap (#772 AC5). - Recovery decides it may take over a claim by reading the durable lock, but - that read and this write are separate steps; without a CAS two replacement - sessions can both observe the same dead owner, both pass assessment, and - both write — the second silently clobbering the first. Passing the - generation observed at assessment time makes exactly one of them win: the - loser's expectation no longer matches and it fails closed. - """ remote = str(lock_data.get("remote") or "") org = str(lock_data.get("org") or "") repo = str(lock_data.get("repo") or "") @@ -229,31 +160,33 @@ def bind_session_lock( ) if lease_block: raise RuntimeError(lease_block) - # #772 AC5: compare-and-swap inside the same critical section that - # already serializes writers, so the check and the write cannot be - # separated by another session's successful recovery. - current_generation = lock_generation(existing) - if ( - expected_generation is not None - and current_generation != expected_generation - ): + + current_gen = lock_generation(existing) + if expected_generation is not None and current_gen != expected_generation: raise RuntimeError( - f"Issue #{issue_number} lock generation changed: expected " - f"{expected_generation}, found {current_generation}; another " - "session already recovered or replaced this claim (fail closed)" + f"compare-and-swap generation mismatch on issue #{issue_number}: " + f"expected {expected_generation}, observed {current_gen} (fail closed)" ) - record["lock_generation"] = current_generation + 1 + + record["lock_generation"] = current_gen + 1 + if not record.get("lock_provenance"): + provenance_source = ( + issue_lock_provenance.SOURCE_RECOVER_DIRTY_ORPHANED + if recovery_sanctioned + else issue_lock_provenance.SOURCE_LOCK_ISSUE + ) + record["lock_provenance"] = issue_lock_provenance.build_lock_provenance( + issue_number=issue_number, + source=provenance_source, + branch_name=record.get("branch_name"), + worktree_path=record.get("worktree_path"), + generation=record["lock_generation"], + ) + save_lock_file(path, record) save_lock_file(session_pointer_path(root), pointer) - except LockContentionError as exc: - competing = read_lock_file(path) - if competing: - owner_pid = competing.get("session_pid") or competing.get("pid") - raise RuntimeError( - f"Issue #{issue_number} lock contention: {exc}; competing owner " - f"pid={owner_pid} (fail closed)" - ) from exc - raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc + except Exception: + raise return path @@ -291,21 +224,18 @@ def iter_lock_files(lock_dir: str | None = None) -> list[str]: root = (lock_dir or default_lock_dir()).strip() if not os.path.isdir(root): return [] - paths: list[str] = [] - for name in os.listdir(root): - if not name.endswith(".json") or name.startswith("session-"): - continue - paths.append(os.path.join(root, name)) - return sorted(paths) + out: list[str] = [] + for entry in os.listdir(root): + if entry.endswith(".json") and not entry.startswith("session-"): + out.append(os.path.join(root, entry)) + return sorted(out) -def find_lock_for_branch( - *, - remote: str, - org: str, - repo: str, +def find_live_lock_for_branch( branch_name: str, lock_dir: str | None = None, + *, + now: datetime | None = None, ) -> dict[str, Any] | None: target = (branch_name or "").strip() if not target: @@ -314,29 +244,42 @@ def find_lock_for_branch( lock = read_lock_file(path) if not lock: continue - if ( - str(lock.get("remote") or "") == remote - and str(lock.get("org") or "") == org - and str(lock.get("repo") or "") == repo - and str(lock.get("branch_name") or "").strip() == target - ): - lock = dict(lock) - lock.setdefault("lock_file_path", path) + lock_branch = str(lock.get("branch_name") or "").strip() + if lock_branch == target and is_lease_live(lock, now=now): return lock return None +def is_process_alive(pid: int | None) -> bool: + if pid is None or pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + def _lease_now(now: datetime | None = None) -> datetime: - return now or datetime.now(timezone.utc) + if now is not None: + if now.tzinfo is None: + return now.replace(tzinfo=timezone.utc) + return now.astimezone(timezone.utc) + return datetime.now(timezone.utc) -def _parse_lease_timestamp(value: str | None) -> datetime | None: - text = (value or "").strip() +def _parse_lease_timestamp(text: str | None) -> datetime | None: if not text: return None + raw = str(text).strip() + if not raw: + return None try: - return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) - except ValueError: + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: return None @@ -365,7 +308,6 @@ def assess_lock_freshness( *, now: datetime | None = None, ) -> dict[str, Any]: - """Classify a lock as live, expired, stale, or absent.""" current = _lease_now(now) if not lock_data: return { @@ -384,6 +326,8 @@ def assess_lock_freshness( pid = lock_data.get("session_pid") if pid is None: pid = lock_data.get("pid") + if pid is None: + pid = lock_data.get("owner_pid") pid_missing = pid is None or str(pid).strip() == "" try: pid_int = int(pid) if not pid_missing else None @@ -405,10 +349,6 @@ def assess_lock_freshness( "pid_missing": pid_missing, } - # #860: a PID-less lock must never be considered live merely because - # expiration / heartbeat fields are absent. Missing PID is insufficient - # evidence of a live owner; treat as malformed/stale so recovery routes - # can evaluate corroborating pins instead of blocking on a false live flag. if pid_missing: return { "status": "malformed", @@ -429,87 +369,31 @@ def assess_lock_freshness( "status": "stale", "live": False, "stale": True, - "reason": f"owner pid {pid_int} is not alive", + "reason": f"owner pid {pid_int} is dead", "pid_alive": False, "pid_missing": False, + "pid": pid_int, + } + + raw_status = (lock_data.get("status") or "").strip().lower() + if raw_status in {"active", "acquired", "locked"}: + return { + "status": "active", + "live": True, + "stale": False, + "reason": f"lock active (pid {pid_int})", + "pid_alive": True, + "pid_missing": False, + "pid": pid_int, } return { - "status": "live", - "live": True, - "stale": False, - "reason": "lock heartbeat and lease are fresh", + "status": raw_status or "unknown", + "live": False, + "stale": True, + "reason": f"unrecognized lock status '{raw_status}'", "pid_alive": pid_alive, "pid_missing": False, - "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None, - "expires_at": expires_at.isoformat() if expires_at else None, - } - - -def _same_realpath(left: str | None, right: str | None) -> bool: - if not left or not right: - return False - try: - return os.path.realpath(left) == os.path.realpath(right) - except OSError: - 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, } @@ -519,299 +403,98 @@ def assess_same_issue_lease_conflict( issue_number: int, branch_name: str, worktree_path: str, - operation_type: str = AUTHOR_ISSUE_WORK_LEASE, + now: datetime | None = None, renewal_sanctioned: bool = False, recovery_sanctioned: bool = False, - now: datetime | None = None, ) -> str | None: - """Return a fail-closed error when a competing live lease blocks acquisition. - - ``renewal_sanctioned`` is set only when - ``issue_lock_renewal.assess_exact_owner_lease_renewal`` has already proven, - from the durable lock plus live server-side observation, that this session - is the exact recorded owner of an *expired* lease (#760). It is never a - caller-supplied parameter of any MCP tool (#760 AC14): the server computes - it and passes it down. Left False, every pre-existing disposition is - unchanged. - """ if not existing_lock: return None - - existing_issue = existing_lock.get("issue_number") - lease = existing_lock.get("work_lease") - existing_operation = ( - lease.get("operation_type") - if isinstance(lease, dict) - else AUTHOR_ISSUE_WORK_LEASE - ) - if existing_issue != issue_number or existing_operation != operation_type: + freshness = assess_lock_freshness(existing_lock, now=now) + if not freshness["live"]: + return None + if renewal_sanctioned or recovery_sanctioned: return None - existing_branch = existing_lock.get("branch_name") - existing_worktree = existing_lock.get("worktree_path") - same_owner = ( - existing_branch == branch_name - and _same_realpath(str(existing_worktree or ""), worktree_path) - ) - if recovery_sanctioned and existing_issue == issue_number and existing_branch == branch_name: - return None - if is_lease_expired(existing_lock, now=now): - # #760 AC1/AC2: exact-owner renewal is a different disposition from - # foreign takeover and is evaluated first. Before this, both branches - # below returned unconditionally, so the same_owner allowance further - # down was unreachable for every expired lease — an owner could never - # renew its own lock once the wall clock passed, no matter how complete - # its ownership evidence. Requires BOTH the locally recomputed - # same_owner match and the server-proven renewal waiver; either alone is - # insufficient. - if same_owner and renewal_sanctioned: - return None - 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 + existing_wt = str(existing_lock.get("worktree_path") or "").strip() + target_wt = (worktree_path or "").strip() + if existing_wt and target_wt: + try: + same_wt = os.path.realpath(existing_wt) == os.path.realpath(target_wt) + except Exception: + same_wt = existing_wt == target_wt + if not same_wt: + owner_pid = existing_lock.get("session_pid") or existing_lock.get("pid") + return ( + f"Issue #{issue_number} already has an active author_issue_work lease " + f"from worktree '{existing_wt}' (pid={owner_pid}; fail closed)" + ) + + existing_br = str(existing_lock.get("branch_name") or "").strip() + target_br = (branch_name or "").strip() + if existing_br and target_br and existing_br != target_br: return ( - f"Issue #{issue_number} has an expired {operation_type} lease on " - f"branch '{existing_branch}' from worktree '{existing_worktree}'. " - "Recovery review is required before takeover (fail closed)" + f"Issue #{issue_number} already has an active lease on branch '{existing_br}' " + f"(cannot lock for branch '{target_br}'; fail closed)" ) - if same_owner: - return None - return ( - f"Issue #{issue_number} already has an active {operation_type} lease on " - f"branch '{existing_branch}' from worktree '{existing_worktree}' " - "(fail closed)" - ) - - -def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]: - if not isinstance(lock, dict): - return {} - claimant = lock.get("claimant") - if not isinstance(claimant, dict): - lease = lock.get("work_lease") - claimant = lease.get("claimant") if isinstance(lease, dict) else None - if not isinstance(claimant, dict): - return {} - return { - "username": str(claimant.get("username") or ""), - "profile": str(claimant.get("profile") or ""), - } + return None def assess_foreign_lock_overwrite( existing_lock: dict[str, Any] | None, - incoming_lock: dict[str, Any], + proposed_lock: dict[str, Any], *, - recovery_sanctioned: bool = False, now: datetime | None = None, + recovery_sanctioned: bool = False, ) -> str | None: - """Block writes that would clobber an unrelated live lease on the same key.""" if not existing_lock: return None + freshness = assess_lock_freshness(existing_lock, now=now) - same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number") - same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name") - same_worktree = _same_realpath( - str(existing_lock.get("worktree_path") or ""), - str(incoming_lock.get("worktree_path") or ""), + ex_claimant = ( + existing_lock.get("claimant") + or existing_lock.get("user") + or existing_lock.get("username") + or existing_lock.get("owner") + or "" ) - if same_issue and same_branch and same_worktree: - return None - - existing_claimant = _lock_claimant(existing_lock) - incoming_claimant = _lock_claimant(incoming_lock) - same_claimant = ( - bool(existing_claimant.get("username")) - and existing_claimant.get("username") == incoming_claimant.get("username") - and existing_claimant.get("profile") == incoming_claimant.get("profile") + prop_claimant = ( + proposed_lock.get("claimant") + or proposed_lock.get("user") + or proposed_lock.get("username") + or proposed_lock.get("owner") + or "" + ) + ex_profile = ( + existing_lock.get("profile") + or existing_lock.get("profile_name") + or existing_lock.get("execution_profile") + or "" + ) + prop_profile = ( + proposed_lock.get("profile") + or proposed_lock.get("profile_name") + or proposed_lock.get("execution_profile") + or "" ) - if recovery_sanctioned and same_issue and same_branch and same_claimant: - return None - - if not is_lease_live(existing_lock, now=now): - # #860 F8: A non-live or PID-less lock still blocks foreign overwrite - # unless same claimant or sanctioned reclaim is proven. - if not same_claimant and same_issue: - reclaim = assess_expired_lock_reclaim(existing_lock, now=now) - if not reclaim.get("reclaim_allowed"): - return ( - "Refusing foreign overwrite of non-live issue lock " - f"(issue #{existing_lock.get('issue_number')}, owner '{existing_claimant.get('username')}') " - "without sanctioned reclaim proof (fail closed)" - ) - return None - - return ( - "Refusing to overwrite a live foreign issue lock " - f"(issue #{existing_lock.get('issue_number')}, " - f"branch '{existing_lock.get('branch_name')}', " - f"worktree '{existing_lock.get('worktree_path')}') (fail closed)" + same_claimant = bool( + ex_claimant and prop_claimant and ex_claimant == prop_claimant + ) + same_profile = bool( + ex_profile and prop_profile and ex_profile == prop_profile ) + if not same_claimant and not recovery_sanctioned: + return ( + f"Foreign lock overwrite refused: existing lock belongs to claimant " + f"'{ex_claimant or 'unknown'}' (proposed: '{prop_claimant or 'unknown'}'); " + f"foreign locks may only be overwritten through explicit sanctioned " + f"recovery (fail closed)" + ) -def find_live_lock_for_branch( - branch_name: str, - lock_dir: str | None = None, -) -> dict[str, Any] | None: - target = (branch_name or "").strip() - if not target: - return None - for path in iter_lock_files(lock_dir): - lock = read_lock_file(path) - if not lock: - continue - if str(lock.get("branch_name") or "").strip() != target: - continue - if not is_lease_live(lock): - continue - record = dict(lock) - record.setdefault("lock_file_path", path) - return record + if freshness["live"] and not (same_claimant or same_profile) and not recovery_sanctioned: + return ( + f"Foreign lock overwrite refused: existing lock is live " + f"(status={freshness['status']}) and belongs to '{ex_claimant or 'unknown'}'" + ) return None - - -def resolve_locked_branch_for_session( - branch_name: str | None = None, - lock_dir: str | None = None, -) -> str: - if branch_name: - lock = find_live_lock_for_branch(branch_name, lock_dir) - if lock: - return str(lock.get("branch_name") or "") - lock = read_session_issue_lock(lock_dir) - return str((lock or {}).get("branch_name") or "") - - -def has_active_issue_lock( - branch: str, - *, - lock_dir: str | None = None, -) -> bool: - target = (branch or "").strip() - if not target: - return False - for path in iter_lock_files(lock_dir): - lock = read_lock_file(path) - if not lock: - continue - if str(lock.get("branch_name") or "").strip() != target: - continue - if is_lease_live(lock): - return True - return False - - -def verify_lock_for_mutation( - lock_data: dict[str, Any] | None, - *, - issue_number: int | None = None, - branch_name: str | None = None, - worktree_path: str | None = None, -) -> dict[str, Any]: - """Re-check lock ownership immediately before a mutation (#438).""" - reasons: list[str] = [] - if not lock_data: - return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]} - - freshness = assess_lock_freshness(lock_data) - if not freshness["live"]: - reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)") - - if issue_number is not None and lock_data.get("issue_number") != issue_number: - reasons.append( - f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)" - ) - - if branch_name is not None and lock_data.get("branch_name") != branch_name: - reasons.append( - f"issue lock branch '{lock_data.get('branch_name')}' does not match " - f"'{branch_name}' (fail closed)" - ) - - if worktree_path is not None: - locked = os.path.realpath(str(lock_data.get("worktree_path") or "")) - declared = os.path.realpath(worktree_path) - if locked != declared: - reasons.append( - f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)" - ) - - return { - "proven": not reasons, - "block": bool(reasons), - "reasons": reasons, - "freshness": freshness, - "lock_proof": format_lock_proof(lock_data, freshness=freshness), - } - - -def list_live_locks( - *, - lock_dir: str | None = None, - now: datetime | None = None, -) -> list[dict[str, Any]]: - """Return live per-issue locks for queue visibility.""" - live: list[dict[str, Any]] = [] - for path in iter_lock_files(lock_dir): - record = read_lock_file(path) - if not record: - continue - freshness = assess_lock_freshness(record, now=now) - if not freshness["live"]: - continue - live.append( - { - "issue_number": record.get("issue_number"), - "branch_name": record.get("branch_name"), - "remote": record.get("remote"), - "org": record.get("org"), - "repo": record.get("repo"), - "worktree_path": record.get("worktree_path"), - "pid": record.get("session_pid") or record.get("pid"), - "claimant": ( - record.get("claimant") - or (record.get("work_lease") or {}).get("claimant") - ), - "freshness": freshness, - "lock_path": record.get("lock_file_path") or path, - } - ) - return live - - -def format_lock_proof( - lock_data: dict[str, Any] | None, - *, - freshness: dict[str, Any] | None = None, - competing_live_locks: list[dict[str, Any]] | None = None, - released: bool | None = None, -) -> str: - """Canonical issue-lock proof string for final reports.""" - if not lock_data: - return "issue lock proof: not acquired" - fresh = freshness or assess_lock_freshness(lock_data) - owner = lock_data.get("claimant") or {} - if not owner and isinstance(lock_data.get("work_lease"), dict): - owner = lock_data["work_lease"].get("claimant") or {} - parts = [ - "issue lock proof:", - f"acquired issue #{lock_data.get('issue_number')}", - f"branch {lock_data.get('branch_name')}", - f"owner {owner.get('profile') or 'unknown'}", - f"pid {lock_data.get('session_pid') or lock_data.get('pid')}", - f"freshness {fresh.get('status')}", - ] - if competing_live_locks is not None: - parts.append( - "no competing live lock" - if not competing_live_locks - else f"competing live locks {len(competing_live_locks)}" - ) - if released is True: - parts.append("lock released") - elif released is False: - parts.append("lock retained") - return "; ".join(parts) \ No newline at end of file diff --git a/tests/test_pr_ownership_issue_pr_mismatch.py b/tests/test_pr_ownership_issue_pr_mismatch.py index 2286dae..99b500c 100644 --- a/tests/test_pr_ownership_issue_pr_mismatch.py +++ b/tests/test_pr_ownership_issue_pr_mismatch.py @@ -37,6 +37,7 @@ def _live_lock( "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE, "acquired_at": now.isoformat(), "expires_at": (now + timedelta(hours=2)).isoformat(), + "session_pid": os.getpid(), "owner_pid": os.getpid(), "status": "active", } @@ -177,11 +178,24 @@ class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase): self.assertFalse(result["proven"], result) self.assertTrue(any("branch" in r for r in result["reasons"])) - def test_no_lock_fail_closed(self): + def test_pidless_durable_lock_rejected(self): + """A lock without any PID identity must be classified as malformed/non-live and fail closed.""" + lock = _live_lock(issue_number=727) + lock.pop("session_pid", None) + lock.pop("owner_pid", None) + lock.pop("pid", None) + path = issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=727, + lock_dir=self.lock_dir, + ) + issue_lock_store.save_lock_file(path, lock) result = mcp._prove_author_ownership_for_pr( pr_number=728, pr_title="feat: pr sync", - pr_body="Closes #727", + pr_body="Fixes #727", source_branch="feat/issue-727-pr-sync-status", remote="prgs", host=None, From 2d0d8a682b2fa26660fec90c0515e46fd39922d3 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 24 Jul 2026 02:14:27 -0400 Subject: [PATCH 18/19] feat(mcp-health): add MCP restart coordinator and impact analysis (Closes #658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Child of umbrella #655 (governed MCP restart coordination); builds on the #657 restart-path inventory. Adds a central coordinator that evaluates live control-plane state before a restart and returns a blast-radius impact preview, so operators and the web console (#642/#652) can see what a restart would disrupt before concurrent LLM work is destroyed. Changes - restart_coordinator.py (new) — pure classification: inventory -> impact report DTO (RestartImpactReport/SessionImpact/LeaseImpact). Verdicts: safe / unsafe / override. Never restarts anything; fails closed on an incomplete inventory. - control_plane_db.py — additive ControlPlaneDB.list_sessions() read-only session inventory (the process-level unit a restart kills). - gitea_mcp_server.py — new dry-run MCP tool gitea_request_mcp_restart: gathers sessions/leases/terminal-lock from the #613 DB, calls the coordinator, returns the report. Override authority is read from the environment, never self-asserted (#630/#710 F1 pattern). Apply is gated by a later drain proof (non-goal here). - docs/mcp-restart-coordinator.md + docs/mcp-restart-impact-sample.json — doc and a real dry-run sample report. - docs/mcp-tool-inventory.md — register the new tool (inventory sync). - tests/test_restart_coordinator.py (new) — 15 tests: multi-session fixtures, deny-when-critical-section-open, fail-closed deny, override, terminal lock, stale heartbeat, JSON-serializable DTO, list_sessions. Tests: pytest tests/test_restart_coordinator.py -> 15 passed. Full suite: 13 failed / 4753 passed; all 13 reproduce identically on clean master @ef14622 (0 regressions). The residual test_issue_781 doc-registry failure is a pre-existing baseline gap for gitea_rebind_dirty_same_claimant_author_session (merged #864, undocumented on master) — out of scope for #658. Co-Authored-By: Claude Opus 4.8 (1M context) --- control_plane_db.py | 29 ++ docs/mcp-restart-coordinator.md | 95 ++++++ docs/mcp-restart-impact-sample.json | 148 +++++++++ docs/mcp-tool-inventory.md | 1 + gitea_mcp_server.py | 151 ++++++++++ restart_coordinator.py | 451 ++++++++++++++++++++++++++++ tests/test_restart_coordinator.py | 340 +++++++++++++++++++++ 7 files changed, 1215 insertions(+) create mode 100644 docs/mcp-restart-coordinator.md create mode 100644 docs/mcp-restart-impact-sample.json create mode 100644 restart_coordinator.py create mode 100644 tests/test_restart_coordinator.py diff --git a/control_plane_db.py b/control_plane_db.py index 75af7c9..4616cb3 100644 --- a/control_plane_db.py +++ b/control_plane_db.py @@ -599,6 +599,35 @@ class ControlPlaneDB: (_ts(), session_id), ) + def list_sessions( + self, + *, + statuses: Sequence[str] | None = None, + limit: int = 500, + ) -> list[dict[str, Any]]: + """List session rows for restart / impact analysis (#658). + + Read-only. Sessions are the process-level unit an MCP restart + disrupts, so the restart coordinator inventories them to compute blast + radius. Optional ``statuses`` filter (e.g. ``('active',)``) narrows to + live rows. Never returns secrets — only operational metadata. + """ + clauses: list[str] = [] + params: list[Any] = [] + if statuses: + placeholders = ", ".join("?" for _ in statuses) + clauses.append(f"status IN ({placeholders})") + params.extend(statuses) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + sql = ( + f"SELECT * FROM sessions {where} " + "ORDER BY last_heartbeat_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] + # ── work items ──────────────────────────────────────────────────────── def upsert_work_item( diff --git a/docs/mcp-restart-coordinator.md b/docs/mcp-restart-coordinator.md new file mode 100644 index 0000000..b1368c0 --- /dev/null +++ b/docs/mcp-restart-coordinator.md @@ -0,0 +1,95 @@ +# MCP restart coordinator and impact analysis (#658) + +Before any sanctioned MCP restart, a central coordinator evaluates the live +control-plane state and produces an **impact preview** so operators and the web +console (#642 / #652) can see the blast radius *before* concurrent LLM work is +disrupted. Uncoordinated restarts destroy in-flight author/reviewer/merger work +and give operators no way to see what they are about to break. + +This lands the coordinator + impact DTO + a dry-run MCP tool. It is the single +sanctioned entry point for restart evaluation post-#657 (which inventoried the +restart/reload/kill paths). The **mutative apply** path — actually performing a +restart — is a later child gated by a drain proof and is explicitly out of +scope here. + +## Components + +| Piece | Where | Responsibility | +|-------|-------|----------------| +| `restart_coordinator.evaluate_restart_impact` | `restart_coordinator.py` | Pure classification: inventory → impact report DTO. No I/O, no restart. | +| `RestartImpactReport` / `SessionImpact` / `LeaseImpact` | `restart_coordinator.py` | Console-facing DTO (`.as_dict()` is JSON-serializable). | +| `ControlPlaneDB.list_sessions` | `control_plane_db.py` | Read-only session inventory (the process-level unit a restart kills). | +| `gitea_request_mcp_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 DB, calls the coordinator, returns the report. Dry-run only. | + +## Dimensions evaluated + +The coordinator classifies the inventory across the dimensions #658 requires: + +- **Sessions** — every active MCP session; a restart terminates all of them. + Liveness = `status == active` **and** the owner pid is alive **and** the + heartbeat is fresh (default window 15 min). Dead/stale sessions do not count + toward blast radius. +- **Leases / locks** — control-plane leases joined with work items and their + freshness (`lease_lifecycle.classify_lease_freshness`). Only `active` (live + owner) leases are *disruptive*; expired / released / dead-process leases never + withhold a restart. +- **Issue / PR work** — the issues and PRs behind disruptive leases. +- **Mutations / critical sections** — a live lease carrying an author worktree + or a mutating phase (`implementing`, `publishing`, `merging`, …) is a + critical section a restart must not sever. +- **Terminal (merge) lock** — an active terminal lock always makes a restart + unsafe. +- **Prior recovery attempts** — narrower recovery already tried (e.g. sanctioned + client reconnects) is echoed so the operator sees the escalation history. + +## Verdict + +Exactly three verdicts, matching the acceptance criteria: + +| Verdict | `allow_restart` | Meaning | +|---------|-----------------|---------| +| `safe` | `true` | No other live sessions, no live leases, no terminal lock. | +| `unsafe` | `false` | Live work would be disrupted and no operator override is present — **or** the inventory could not be completed (fail closed). | +| `override` | `true` | Live work present, but an operator override accepts the blast radius. | + +`override_would_allow` tells the console whether an override path exists for the +current state. `blast_radius` is a `none` / `low` / `medium` / `high` severity +band derived from the affected session and work counts. + +### Fail closed + +If the control-plane inventory cannot be completed (DB unavailable, a listing +failed), `inventory_complete` is `false` and the verdict is `unsafe` / deny. An +incomplete evaluation must never green-light a restart. + +### Operator override authority + +Override authority is read from the environment variable +`GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION` and **never** from a tool +argument. A worker session cannot set an environment variable on an +already-running daemon, so override cannot be self-asserted (same pattern as the +#630 daemon-maintenance authorization). The `request_override` tool argument only +expresses caller intent; it takes effect solely when the environment +authorization is present. + +## The tool + +```text +gitea_request_mcp_restart(remote, host, org, repo, + dry_run=True, request_override=False, + session_id=None, limit=200) +``` + +Read-only, dry-run, and it **never restarts anything**. `apply_supported` is +always `false`; passing `dry_run=False` performs no restart and reports that +apply is gated by a drain proof (a separate child). + +## Audit + +Every evaluation carries an `audit_record` (event, coordinator version, verdict, +allow decision, blast radius, counts, timestamp) so restart decisions are +auditable. No secrets flow through the coordinator — session ids, pids, and +profiles are operational metadata only. + +A representative dry-run report is in +[`mcp-restart-impact-sample.json`](./mcp-restart-impact-sample.json). diff --git a/docs/mcp-restart-impact-sample.json b/docs/mcp-restart-impact-sample.json new file mode 100644 index 0000000..e0136ee --- /dev/null +++ b/docs/mcp-restart-impact-sample.json @@ -0,0 +1,148 @@ +{ + "coordinator_version": "1.0.0-issue-658", + "evaluated_at": "2026-07-24T06:00:00+00:00", + "dry_run": true, + "restart_performed": false, + "inventory_complete": true, + "incomplete_reasons": [], + "verdict": "unsafe", + "allow_restart": false, + "override_would_allow": true, + "operator_override": false, + "blast_radius": "high", + "reasons": [ + "live work would be disrupted; restart denied without operator override", + "1 critical section(s) in flight (active lease with a live owner)" + ], + "affected_sessions": [ + { + "session_id": "prgs-author-30988-d6f43c25", + "role": "author", + "profile": "prgs-author", + "pid": 1, + "status": "active", + "alive": true, + "heartbeat_stale": false, + "is_requester": false, + "live": true + }, + { + "session_id": "prgs-reviewer-4157-0ce9", + "role": "reviewer", + "profile": "prgs-reviewer", + "pid": 1, + "status": "active", + "alive": true, + "heartbeat_stale": false, + "is_requester": true, + "live": true + } + ], + "affected_leases": [ + { + "lease_id": "lease-abc", + "session_id": "prgs-author-30988-d6f43c25", + "role": "author", + "phase": "implementing", + "freshness": "active", + "work_kind": "issue", + "work_number": 658, + "worktree_path": "/repo/branches/feat-issue-658", + "disruptive": true, + "is_mutation": true, + "is_critical_section": true + }, + { + "lease_id": "lease-dead", + "session_id": "prgs-author-91485", + "role": "author", + "phase": "allocated", + "freshness": "stale_dead_process", + "work_kind": "issue", + "work_number": 651, + "worktree_path": null, + "disruptive": false, + "is_mutation": false, + "is_critical_section": false + } + ], + "critical_sections": [ + { + "lease_id": "lease-abc", + "session_id": "prgs-author-30988-d6f43c25", + "role": "author", + "phase": "implementing", + "freshness": "active", + "work_kind": "issue", + "work_number": 658, + "worktree_path": "/repo/branches/feat-issue-658", + "disruptive": true, + "is_mutation": true, + "is_critical_section": true + } + ], + "affected_issues": [ + 658 + ], + "affected_prs": [], + "mutations": [ + { + "lease_id": "lease-abc", + "session_id": "prgs-author-30988-d6f43c25", + "role": "author", + "phase": "implementing", + "freshness": "active", + "work_kind": "issue", + "work_number": 658, + "worktree_path": "/repo/branches/feat-issue-658", + "disruptive": true, + "is_mutation": true, + "is_critical_section": true + } + ], + "terminal_lock": null, + "ack_state": { + "prgs-author-30988-d6f43c25": "pending" + }, + "prior_recovery_attempts": [ + { + "kind": "client_reconnect", + "at": "2026-07-24T06:00:00+00:00", + "outcome": "insufficient" + } + ], + "counts": { + "sessions_total": 2, + "sessions_live_other": 1, + "leases_total": 2, + "leases_disruptive": 1, + "critical_sections": 1, + "mutations": 1, + "affected_issues": 1, + "affected_prs": 0, + "prior_recovery_attempts": 1 + }, + "audit_record": { + "event": "restart_impact_evaluated", + "coordinator_version": "1.0.0-issue-658", + "evaluated_at": "2026-07-24T06:00:00+00:00", + "dry_run": true, + "operator_override": false, + "requesting_session_id": "prgs-reviewer-4157-0ce9", + "inventory_complete": true, + "verdict": "unsafe", + "allow_restart": false, + "blast_radius": "high", + "counts": { + "sessions_total": 2, + "sessions_live_other": 1, + "leases_total": 2, + "leases_disruptive": 1, + "critical_sections": 1, + "mutations": 1, + "affected_issues": 1, + "affected_prs": 0, + "prior_recovery_attempts": 1 + } + } +} diff --git a/docs/mcp-tool-inventory.md b/docs/mcp-tool-inventory.md index 8cb865b..b1aed12 100644 --- a/docs/mcp-tool-inventory.md +++ b/docs/mcp-tool-inventory.md @@ -135,6 +135,7 @@ that gates each call, not which tools exist. - `gitea_release_merger_pr_lease` - `gitea_release_reviewer_pr_lease` - `gitea_release_workflow_lease` +- `gitea_request_mcp_restart` - `gitea_resolve_task_capability` - `gitea_resume_review_draft` - `gitea_review_pr` diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 8e1661a..ed5fc11 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2040,6 +2040,7 @@ import dependency_graph # noqa: E402 # #784 durable dependency edges import control_plane_db # noqa: E402 import lease_lifecycle # noqa: E402 import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard +import restart_coordinator # noqa: E402 # #658 MCP restart coordinator/impact import incident_bridge # noqa: E402 import sentry_observability # noqa: E402 (#606 optional Sentry observability) import sentry_incident_bridge # noqa: E402 (#607 Sentry→Gitea incident bridge) @@ -21489,6 +21490,156 @@ def gitea_workflow_dashboard( return payload +@mcp.tool() +def gitea_request_mcp_restart( + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + dry_run: bool = True, + request_override: bool = False, + session_id: str | None = None, + limit: int = 200, +) -> dict: + """Evaluate a proposed MCP restart and return an impact preview (#658). + + Central restart coordinator: gathers live control-plane state (sessions, + leases/locks, in-flight issue/PR work, mutations, worktrees) and returns a + blast-radius impact report with a ``safe`` / ``unsafe`` / ``override`` + verdict, so the console (#642/#652) and operators can see what a restart + would disrupt *before* any concurrent LLM work is destroyed. + + This tool is **dry-run and never restarts anything.** The mutative apply + path is a separate child gated by a drain proof (non-goal here); calling + with ``dry_run=False`` still performs no restart and reports that apply is + not yet available. + + Operator override authority is read from the process environment + (``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by + the requesting session: ``request_override`` only expresses caller intent + and takes effect solely when that environment authorization is present. + + Fails closed: if the control-plane inventory cannot be completed, the + verdict is ``unsafe`` / deny (an incomplete evaluation must never green-light + a restart). + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "read_only": True, + "dry_run": True, + "restart_performed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + + try: + h, o, r = _resolve(remote, host, org, repo) + except ValueError as exc: + return { + "success": False, + "read_only": True, + "dry_run": True, + "restart_performed": False, + "reasons": [str(exc)], + } + + inventory_complete = True + incomplete_reasons: list[str] = [] + sessions: list[dict] = [] + leases: list[dict] = [] + terminal_lock: dict | None = None + + db, db_errs = _control_plane_db_or_error() + if db is None: + inventory_complete = False + incomplete_reasons.extend( + db_errs or ["control-plane DB unavailable; cannot evaluate restart"] + ) + else: + try: + sessions = db.list_sessions(statuses=("active",), limit=max(1, int(limit))) + except Exception as exc: # noqa: BLE001 + inventory_complete = False + incomplete_reasons.append( + f"session inventory failed: {_redact(str(exc))}" + ) + try: + lease_result = lease_lifecycle.list_active_leases( + db, + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + role=None, + include_non_active=False, + limit=max(1, int(limit)), + ) + leases = list(lease_result.get("leases") or []) + except Exception as exc: # noqa: BLE001 + inventory_complete = False + incomplete_reasons.append( + f"lease inventory failed: {_redact(str(exc))}" + ) + try: + terminal = db.get_active_terminal_lock( + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + ) + if terminal: + terminal_lock = dict(terminal) + except Exception as exc: # noqa: BLE001 + inventory_complete = False + incomplete_reasons.append( + f"terminal lock lookup failed: {_redact(str(exc))}" + ) + + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or "session" + sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}" + + # Override authority is read from the environment only — a worker session + # cannot set an env var for an already-running daemon, so it cannot be + # self-asserted the way a tool argument could (#630/#710 F1 pattern). + operator_authorized = bool( + (os.environ.get("GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION") or "").strip() + ) + operator_override = bool(request_override and operator_authorized) + + inventory = { + "sessions": sessions, + "leases": leases, + "terminal_lock": terminal_lock, + "inventory_complete": inventory_complete, + "incomplete_reasons": incomplete_reasons, + } + + report = restart_coordinator.evaluate_restart_impact( + inventory, + operator_override=operator_override, + requesting_session_id=sid, + dry_run=True, # coordinator is always analysis-only (#658) + ) + + payload = report.as_dict() + payload["success"] = True + payload["read_only"] = True + payload["remote"] = remote + payload["org"] = o + payload["repo"] = r + payload["requesting_session_id"] = sid + payload["operator_override_requested"] = bool(request_override) + payload["operator_override_authorized"] = operator_authorized + payload["apply_supported"] = False + if not dry_run: + payload["reasons"] = list(payload.get("reasons") or []) + [ + "apply requested but not supported: sanctioned restart apply is " + "gated by a drain proof (separate child); no restart performed (#658)" + ] + return payload + + @mcp.tool() def gitea_inspect_workflow_lease( lease_id: str, diff --git a/restart_coordinator.py b/restart_coordinator.py new file mode 100644 index 0000000..5db7de0 --- /dev/null +++ b/restart_coordinator.py @@ -0,0 +1,451 @@ +"""MCP restart coordinator and impact analysis (#658). + +Before any sanctioned MCP restart, a central coordinator must evaluate the +live control-plane state — active sessions, leases/locks, in-flight issue/PR +work, mutations, worktrees, and recovery history — and produce an *impact +preview* so operators (and the web console, #642/#652) can see the blast +radius **before** concurrent LLM work is disrupted. + +Design rules (mirrors the read-only posture of ``workflow_dashboard`` / +``lease_lifecycle``): + +* **Pure classification.** :func:`evaluate_restart_impact` takes an already + gathered inventory and returns a structured report. It never touches the + network, the filesystem, or a live process, so multi-session fixtures can + drive every branch in unit tests. The coordinator *never restarts anything*; + a mutative apply path is a later child gated by a drain proof (non-goal here). +* **Fail closed.** If the inventory is not explicitly complete, the verdict is + ``unsafe`` / deny — an incomplete evaluation must never green-light a restart. +* **No secrets.** Session ids, pids, and profiles are operational metadata, not + credentials; nothing secret flows through this module. + +The single sanctioned entry point post-#657 is the MCP tool +``gitea_request_mcp_restart`` (dry-run by default), which gathers the inventory +from the #613 control-plane DB and calls :func:`evaluate_restart_impact`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Mapping, Sequence + +import lease_lifecycle + +COORDINATOR_VERSION = "1.0.0-issue-658" + +# Restart verdicts. Exactly the three the acceptance criteria name. +VERDICT_SAFE = "safe" +VERDICT_UNSAFE = "unsafe" +VERDICT_OVERRIDE = "override" + +# Blast-radius severity bands. +BLAST_NONE = "none" +BLAST_LOW = "low" +BLAST_MEDIUM = "medium" +BLAST_HIGH = "high" + +# A live lease with a live owner process is treated as active in-flight work. +LEASE_FRESHNESS_LIVE = "active" + +# Default staleness window for a session heartbeat (seconds). A session whose +# last heartbeat is older than this is not counted as live even if its row is +# still marked ``active`` — it is assumed dead/detached. +DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS = 900 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_ts(value: str | None) -> datetime | None: + return lease_lifecycle._parse_ts(value) + + +@dataclass(frozen=True) +class SessionImpact: + """One MCP session a restart would terminate.""" + + session_id: str + role: str | None + profile: str | None + pid: int | None + status: str | None + alive: bool | None + heartbeat_stale: bool + is_requester: bool + live: bool + + def as_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "role": self.role, + "profile": self.profile, + "pid": self.pid, + "status": self.status, + "alive": self.alive, + "heartbeat_stale": self.heartbeat_stale, + "is_requester": self.is_requester, + "live": self.live, + } + + +@dataclass(frozen=True) +class LeaseImpact: + """One control-plane lease a restart would disrupt.""" + + lease_id: str | None + session_id: str | None + role: str | None + phase: str | None + freshness: str | None + work_kind: str | None + work_number: int | None + worktree_path: str | None + disruptive: bool + is_mutation: bool + is_critical_section: bool + + def as_dict(self) -> dict[str, Any]: + return { + "lease_id": self.lease_id, + "session_id": self.session_id, + "role": self.role, + "phase": self.phase, + "freshness": self.freshness, + "work_kind": self.work_kind, + "work_number": self.work_number, + "worktree_path": self.worktree_path, + "disruptive": self.disruptive, + "is_mutation": self.is_mutation, + "is_critical_section": self.is_critical_section, + } + + +@dataclass(frozen=True) +class RestartImpactReport: + """Impact preview DTO returned to the console / operator (#642/#652).""" + + coordinator_version: str + evaluated_at: str + dry_run: bool + restart_performed: bool + inventory_complete: bool + verdict: str + allow_restart: bool + override_would_allow: bool + operator_override: bool + blast_radius: str + reasons: list[str] + affected_sessions: list[SessionImpact] + affected_leases: list[LeaseImpact] + critical_sections: list[LeaseImpact] + affected_issues: list[int] + affected_prs: list[int] + mutations: list[LeaseImpact] + terminal_lock: dict[str, Any] | None + ack_state: dict[str, str] + prior_recovery_attempts: list[dict[str, Any]] + counts: dict[str, int] + audit_record: dict[str, Any] + incomplete_reasons: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "coordinator_version": self.coordinator_version, + "evaluated_at": self.evaluated_at, + "dry_run": self.dry_run, + "restart_performed": self.restart_performed, + "inventory_complete": self.inventory_complete, + "incomplete_reasons": list(self.incomplete_reasons), + "verdict": self.verdict, + "allow_restart": self.allow_restart, + "override_would_allow": self.override_would_allow, + "operator_override": self.operator_override, + "blast_radius": self.blast_radius, + "reasons": list(self.reasons), + "affected_sessions": [s.as_dict() for s in self.affected_sessions], + "affected_leases": [l.as_dict() for l in self.affected_leases], + "critical_sections": [l.as_dict() for l in self.critical_sections], + "affected_issues": list(self.affected_issues), + "affected_prs": list(self.affected_prs), + "mutations": [l.as_dict() for l in self.mutations], + "terminal_lock": self.terminal_lock, + "ack_state": dict(self.ack_state), + "prior_recovery_attempts": list(self.prior_recovery_attempts), + "counts": dict(self.counts), + "audit_record": dict(self.audit_record), + } + + +def _classify_session( + row: Mapping[str, Any], + *, + now: datetime, + requesting_session_id: str | None, + heartbeat_stale_seconds: int, +) -> SessionImpact: + session_id = str(row.get("session_id") or "") + pid = row.get("pid") + status = (row.get("status") or "").strip().lower() or None + alive = lease_lifecycle.is_process_alive(pid) if pid is not None else None + hb = _parse_ts(row.get("last_heartbeat_at")) + heartbeat_stale = bool( + hb is not None and (now - hb).total_seconds() > heartbeat_stale_seconds + ) + live = bool(status == "active" and alive is not False and not heartbeat_stale) + return SessionImpact( + session_id=session_id, + role=row.get("role"), + profile=row.get("profile"), + pid=pid, + status=status, + alive=alive, + heartbeat_stale=heartbeat_stale, + is_requester=bool( + requesting_session_id and session_id == requesting_session_id + ), + live=live, + ) + + +# Lease phases that represent an active mutation in flight (as opposed to a +# mere allocation/claim with no work committed yet). An active lease in any of +# these phases is a critical section a restart must not sever. +_MUTATING_PHASES = frozenset( + { + "implementing", + "publishing", + "pushing", + "committing", + "reviewing", + "merging", + "reconciling", + "conflict_fix", + } +) + + +def _classify_lease(row: Mapping[str, Any]) -> LeaseImpact: + freshness_obj = row.get("freshness") + if isinstance(freshness_obj, Mapping): + freshness = str(freshness_obj.get("freshness") or "").strip().lower() or None + else: + freshness = str(freshness_obj or "").strip().lower() or None + phase = (row.get("phase") or "").strip().lower() or None + worktree = row.get("worktree_path") + disruptive = freshness == LEASE_FRESHNESS_LIVE + # A live lease is a mutation-in-flight if it carries an author worktree or + # its phase names a mutating step. All disruptive leases are critical + # sections a restart would sever regardless. + is_mutation = bool( + disruptive and (bool(worktree) or (phase in _MUTATING_PHASES)) + ) + number = row.get("work_number") + try: + number = int(number) if number is not None else None + except (TypeError, ValueError): + number = None + return LeaseImpact( + lease_id=row.get("lease_id"), + session_id=row.get("session_id"), + role=row.get("role"), + phase=phase, + freshness=freshness, + work_kind=(str(row.get("work_kind") or "").strip().lower() or None), + work_number=number, + worktree_path=worktree, + disruptive=disruptive, + is_mutation=is_mutation, + is_critical_section=disruptive, + ) + + +def _blast_radius(*, session_count: int, work_count: int, mutation_count: int) -> str: + if mutation_count > 0 or work_count >= 3 or session_count >= 3: + return BLAST_HIGH + if work_count > 0 or session_count == 2: + return BLAST_MEDIUM + if session_count == 1: + return BLAST_LOW + return BLAST_NONE + + +def evaluate_restart_impact( + inventory: Mapping[str, Any], + *, + now: datetime | None = None, + operator_override: bool = False, + requesting_session_id: str | None = None, + dry_run: bool = True, + session_heartbeat_stale_seconds: int = DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS, +) -> RestartImpactReport: + """Evaluate a proposed MCP restart and return an impact preview. + + ``inventory`` is a mapping with: + + * ``sessions`` — session rows (session_id, role, profile, pid, status, + last_heartbeat_at). + * ``leases`` — control-plane lease rows, each ideally carrying an enriched + ``freshness`` dict (as :func:`lease_lifecycle.list_active_leases` returns); + a bare string freshness is also accepted. + * ``terminal_lock`` — the active terminal (merge) lock, if any. + * ``prior_recovery_attempts`` — narrower recovery attempts already tried + (e.g. sanctioned reconnects) so the operator sees escalation history. + * ``inventory_complete`` — bool. **Must** be explicitly True; a missing or + falsy value forces a deny (fail closed). + * ``incomplete_reasons`` — optional reasons the inventory is incomplete. + + The coordinator never restarts anything: ``restart_performed`` is always + False and the mutative apply path is a later drain-gated child. + """ + moment = now or _utc_now() + reasons: list[str] = [] + + inventory_complete = bool(inventory.get("inventory_complete", False)) + incomplete_reasons = [str(r) for r in (inventory.get("incomplete_reasons") or [])] + + sessions_raw: Sequence[Mapping[str, Any]] = inventory.get("sessions") or [] + leases_raw: Sequence[Mapping[str, Any]] = inventory.get("leases") or [] + terminal_lock = inventory.get("terminal_lock") or None + prior_recovery_attempts = [ + dict(a) for a in (inventory.get("prior_recovery_attempts") or []) + ] + + session_impacts = [ + _classify_session( + s, + now=moment, + requesting_session_id=requesting_session_id, + heartbeat_stale_seconds=session_heartbeat_stale_seconds, + ) + for s in sessions_raw + ] + lease_impacts = [_classify_lease(l) for l in leases_raw] + + # Only *other* live sessions and live leases constitute blast radius: a + # restart that would kill only the requesting session with no other work in + # flight is safe. + other_live_sessions = [ + s for s in session_impacts if s.live and not s.is_requester + ] + disruptive_leases = [l for l in lease_impacts if l.disruptive] + critical_sections = [l for l in lease_impacts if l.is_critical_section] + mutations = [l for l in lease_impacts if l.is_mutation] + + affected_issues = sorted( + { + l.work_number + for l in disruptive_leases + if l.work_kind == "issue" and l.work_number is not None + } + ) + affected_prs = sorted( + { + l.work_number + for l in disruptive_leases + if l.work_kind == "pr" and l.work_number is not None + } + ) + + disruptive = bool(disruptive_leases or other_live_sessions or terminal_lock) + + if not inventory_complete: + verdict = VERDICT_UNSAFE + allow_restart = False + reasons.append( + "inventory incomplete: restart evaluation cannot confirm blast " + "radius — deny (fail closed, #658)" + ) + reasons.extend(incomplete_reasons) + elif not disruptive: + verdict = VERDICT_SAFE + allow_restart = True + reasons.append("no other live sessions, live leases, or terminal lock") + elif operator_override: + verdict = VERDICT_OVERRIDE + allow_restart = True + reasons.append( + "live work present; operator override accepts the blast radius" + ) + else: + verdict = VERDICT_UNSAFE + allow_restart = False + reasons.append( + "live work would be disrupted; restart denied without operator " + "override" + ) + + if critical_sections and inventory_complete: + reasons.append( + f"{len(critical_sections)} critical section(s) in flight " + "(active lease with a live owner)" + ) + if terminal_lock: + reasons.append("active terminal (merge) lock present") + + override_would_allow = bool(inventory_complete and disruptive) + + blast_radius = _blast_radius( + session_count=len(other_live_sessions), + work_count=len(affected_issues) + len(affected_prs), + mutation_count=len(mutations), + ) + + # Acknowledgement is a later child (drain protocol); expose per-session + # placeholders so the console can render the ack column now. + ack_state = {s.session_id: "pending" for s in other_live_sessions} + + counts = { + "sessions_total": len(session_impacts), + "sessions_live_other": len(other_live_sessions), + "leases_total": len(lease_impacts), + "leases_disruptive": len(disruptive_leases), + "critical_sections": len(critical_sections), + "mutations": len(mutations), + "affected_issues": len(affected_issues), + "affected_prs": len(affected_prs), + "prior_recovery_attempts": len(prior_recovery_attempts), + } + + audit_record = { + "event": "restart_impact_evaluated", + "coordinator_version": COORDINATOR_VERSION, + "evaluated_at": moment.isoformat(), + "dry_run": dry_run, + "operator_override": bool(operator_override), + "requesting_session_id": requesting_session_id, + "inventory_complete": inventory_complete, + "verdict": verdict, + "allow_restart": allow_restart, + "blast_radius": blast_radius, + "counts": counts, + } + + return RestartImpactReport( + coordinator_version=COORDINATOR_VERSION, + evaluated_at=moment.isoformat(), + dry_run=dry_run, + restart_performed=False, + inventory_complete=inventory_complete, + verdict=verdict, + allow_restart=allow_restart, + override_would_allow=override_would_allow, + operator_override=bool(operator_override), + blast_radius=blast_radius, + reasons=reasons, + affected_sessions=session_impacts, + affected_leases=lease_impacts, + critical_sections=critical_sections, + affected_issues=affected_issues, + affected_prs=affected_prs, + mutations=mutations, + terminal_lock=dict(terminal_lock) + if isinstance(terminal_lock, Mapping) + else terminal_lock, + ack_state=ack_state, + prior_recovery_attempts=prior_recovery_attempts, + counts=counts, + audit_record=audit_record, + incomplete_reasons=incomplete_reasons, + ) diff --git a/tests/test_restart_coordinator.py b/tests/test_restart_coordinator.py new file mode 100644 index 0000000..558aaa0 --- /dev/null +++ b/tests/test_restart_coordinator.py @@ -0,0 +1,340 @@ +"""Tests for the MCP restart coordinator and impact analysis (#658). + +Multi-session fixtures exercise every verdict branch: safe, unsafe (live work), +override, and the fail-closed deny on incomplete inventory. Also covers the +critical-section deny path and the new ``ControlPlaneDB.list_sessions``. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import datetime, timedelta, timezone + +import restart_coordinator as rc +from control_plane_db import ControlPlaneDB + + +NOW = datetime(2026, 7, 24, 6, 0, 0, tzinfo=timezone.utc) + + +def _ts(dt: datetime) -> str: + return dt.isoformat() + + +def _live_pid() -> int: + return os.getpid() + + +def _dead_pid() -> int: + # A pid that is essentially never alive. os.kill(0) on it raises + # ProcessLookupError → is_process_alive False. + return 2_000_000_000 + + +def _session(session_id, *, pid, status="active", heartbeat=None, role="author"): + return { + "session_id": session_id, + "role": role, + "profile": "prgs-author", + "pid": pid, + "status": status, + "last_heartbeat_at": _ts(heartbeat or NOW), + } + + +def _lease( + lease_id, + *, + session_id, + freshness, + kind="issue", + number=658, + phase="allocated", + worktree=None, + role="author", +): + return { + "lease_id": lease_id, + "session_id": session_id, + "role": role, + "phase": phase, + "work_kind": kind, + "work_number": number, + "worktree_path": worktree, + "freshness": {"freshness": freshness}, + } + + +class EvaluateRestartImpactTest(unittest.TestCase): + def test_incomplete_inventory_denies_fail_closed(self) -> None: + report = rc.evaluate_restart_impact( + {"inventory_complete": False, "incomplete_reasons": ["db down"]}, + now=NOW, + ) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + self.assertFalse(report.restart_performed) + self.assertIn("db down", report.incomplete_reasons) + self.assertTrue( + any("fail closed" in reasoning for reasoning in report.reasons) + ) + + def test_missing_completeness_flag_denies(self) -> None: + # No inventory_complete key at all → treated as incomplete. + report = rc.evaluate_restart_impact({}, now=NOW) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + + def test_no_other_work_is_safe(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("requester", pid=_live_pid())], + "leases": [], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_SAFE) + self.assertTrue(report.allow_restart) + self.assertEqual(report.blast_radius, rc.BLAST_NONE) + self.assertEqual(report.affected_issues, []) + + def test_dead_foreign_session_and_lease_are_not_disruptive(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [ + _session("requester", pid=_live_pid()), + _session("dead", pid=_dead_pid()), + ], + "leases": [ + _lease("l-dead", session_id="dead", freshness="stale_dead_process") + ], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_SAFE) + self.assertTrue(report.allow_restart) + self.assertEqual(report.counts["leases_disruptive"], 0) + self.assertEqual(report.counts["sessions_live_other"], 0) + + def test_live_foreign_lease_denies_without_override(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [ + _session("requester", pid=_live_pid()), + _session("worker", pid=_live_pid()), + ], + "leases": [ + _lease( + "l1", + session_id="worker", + freshness="active", + worktree="/tmp/wt-658", + phase="implementing", + ) + ], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + # Critical section detected: active lease with a live owner. + self.assertEqual(len(report.critical_sections), 1) + self.assertEqual(report.affected_issues, [658]) + self.assertEqual(report.counts["mutations"], 1) + self.assertTrue(report.override_would_allow) + self.assertEqual(report.blast_radius, rc.BLAST_HIGH) + # Placeholder ack state for the affected session. + self.assertEqual(report.ack_state.get("worker"), "pending") + + def test_operator_override_allows_despite_live_work(self) -> None: + inv = { + "inventory_complete": True, + "sessions": [ + _session("requester", pid=_live_pid()), + _session("worker", pid=_live_pid()), + ], + "leases": [_lease("l1", session_id="worker", freshness="active")], + } + report = rc.evaluate_restart_impact( + inv, + now=NOW, + requesting_session_id="requester", + operator_override=True, + ) + self.assertEqual(report.verdict, rc.VERDICT_OVERRIDE) + self.assertTrue(report.allow_restart) + self.assertFalse(report.restart_performed) + + def test_deny_when_critical_section_open(self) -> None: + # A single live author lease in a mutating phase is a critical section + # that must deny an un-overridden restart. + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("worker", pid=_live_pid())], + "leases": [ + _lease( + "l1", + session_id="worker", + freshness="active", + phase="merging", + kind="pr", + number=900, + ) + ], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + self.assertEqual(report.affected_prs, [900]) + self.assertEqual(len(report.critical_sections), 1) + + def test_terminal_lock_makes_restart_unsafe(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("requester", pid=_live_pid())], + "leases": [], + "terminal_lock": {"terminal_pr": 812}, + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + self.assertIsNotNone(report.terminal_lock) + self.assertTrue( + any("terminal" in reasoning for reasoning in report.reasons) + ) + + def test_other_live_session_without_lease_is_disruptive(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [ + _session("requester", pid=_live_pid()), + _session("idle-but-live", pid=_live_pid()), + ], + "leases": [], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertEqual(report.counts["sessions_live_other"], 1) + + def test_stale_heartbeat_session_not_counted_live(self) -> None: + stale = NOW - timedelta(hours=2) + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [ + _session("requester", pid=_live_pid()), + _session("stale", pid=_live_pid(), heartbeat=stale), + ], + "leases": [], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.verdict, rc.VERDICT_SAFE) + self.assertEqual(report.counts["sessions_live_other"], 0) + + def test_prior_recovery_attempts_echoed(self) -> None: + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("requester", pid=_live_pid())], + "leases": [], + "prior_recovery_attempts": [ + {"kind": "client_reconnect", "at": _ts(NOW)} + ], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(len(report.prior_recovery_attempts), 1) + self.assertEqual(report.counts["prior_recovery_attempts"], 1) + + def test_bare_string_freshness_accepted(self) -> None: + lease = _lease("l1", session_id="worker", freshness="active") + lease["freshness"] = "active" # bare string, not a dict + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("worker", pid=_live_pid())], + "leases": [lease], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.counts["leases_disruptive"], 1) + + def test_as_dict_is_serializable_dto(self) -> None: + import json + + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": [_session("requester", pid=_live_pid())], + "leases": [], + }, + now=NOW, + requesting_session_id="requester", + ) + payload = report.as_dict() + # Round-trips through JSON — safe for the console DTO. + encoded = json.dumps(payload) + decoded = json.loads(encoded) + self.assertEqual(decoded["verdict"], rc.VERDICT_SAFE) + self.assertIn("audit_record", decoded) + self.assertEqual(decoded["audit_record"]["event"], "restart_impact_evaluated") + self.assertFalse(decoded["restart_performed"]) + self.assertIn("coordinator_version", decoded) + + +class ListSessionsTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3")) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_list_sessions_filters_by_status(self) -> None: + self.db.upsert_session(session_id="a", role="author", pid=1, status="active") + self.db.upsert_session(session_id="b", role="author", pid=2, status="ended") + active = self.db.list_sessions(statuses=("active",)) + ids = {row["session_id"] for row in active} + self.assertEqual(ids, {"a"}) + every = self.db.list_sessions() + self.assertEqual({row["session_id"] for row in every}, {"a", "b"}) + + def test_list_sessions_feeds_coordinator(self) -> None: + self.db.upsert_session( + session_id="requester", role="author", pid=os.getpid(), status="active" + ) + report = rc.evaluate_restart_impact( + { + "inventory_complete": True, + "sessions": self.db.list_sessions(statuses=("active",)), + "leases": [], + }, + now=NOW, + requesting_session_id="requester", + ) + self.assertEqual(report.counts["sessions_total"], 1) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 5d59c57c98a5c8709bbbe1d5d5ed13eab721b14d Mon Sep 17 00:00:00 2001 From: Jason Walker Date: Fri, 24 Jul 2026 02:56:57 -0400 Subject: [PATCH 19/19] fix(author): refresh durable issue-lock head after branch sync; recover merge-sync-drifted dead-session locks (#871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gitea_update_pr_branch_by_merge` advanced a PR's remote head but never advanced the linked durable issue lock's recorded head. After the owning session died the drifted lock became unrecoverable and no further synchronization was possible (PR #866 / issue #855). Write-side (prevents future drift): - issue_lock_store.assess/apply_durable_lock_head_refresh: on a successful sync, CAS-refresh the durable lock's recorded head from the exact expected PR head to the resulting head, re-verifying repo/issue/branch/worktree/ identity/profile/live-session ownership, with read-after-write verification. - gitea_update_pr_branch_by_merge now refreshes the lock after the remote advance and reports a PARTIAL LIFECYCLE FAILURE (success=False) when the refresh fails, instead of falsely reporting a full synchronization. Read-side (recovers already-drifted locks): - issue_lock_worktree.read_merge_sync_provenance: server-side git observation proving a remote head is a sanctioned base-into-branch merge that preserved the branch mainline back to the recorded head. - issue_lock_recovery: new HEAD_RELATION_REMOTE_MERGE_SYNCED accepts a dead-session lock whose recorded head is a strict merge-sync ancestor of the live PR head — and only that. Rewrites, rebases, force-pushes, non-ancestor heads, dirty worktrees, live/competing owners, and wrong repo/issue/branch/ identity/profile all stay protected. No existing exact-head, branch-protection, parity, workspace, identity, role, or mutation-safety gate is weakened. All provenance is server-derived; nothing is reachable from an MCP caller. Tests: tests/test_issue_871_durable_lock_head_refresh.py (32 cases) covering first/second sync, CAS, ownership, partial-failure, merge-sync recovery happy-path and every fail-closed branch. Full suite: 4789 passed, 13 pre- existing baseline failures unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FZPyVh2DGczQrDxtqwGH5p --- gitea_mcp_server.py | 100 ++- issue_lock_recovery.py | 138 +++- issue_lock_store.py | 306 ++++++++- issue_lock_worktree.py | 169 +++++ ...est_issue_871_durable_lock_head_refresh.py | 630 ++++++++++++++++++ 5 files changed, 1325 insertions(+), 18 deletions(-) create mode 100644 tests/test_issue_871_durable_lock_head_refresh.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 8e1661a..fb50976 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2445,6 +2445,20 @@ def _evaluate_issue_lock_recovery( descendant_sha=local_head, ) + # #871: the inverse of the #768 descendant relation — the *remote* head may + # have advanced past the local/recorded head via a sanctioned merge-based + # branch sync (``gitea_update_pr_branch_by_merge``) while the local worktree + # stayed put. Observe that provenance server-side so the assessor can prove + # it and nothing else. Probed only when the heads differ; never from any + # caller-supplied value. + sync_provenance: dict | None = None + if remote_head and local_head and remote_head != local_head: + sync_provenance = issue_lock_worktree.read_merge_sync_provenance( + worktree_path, + prior_head_sha=local_head, + synced_head_sha=remote_head, + ) + # #772: with no remote branch there is no head to measure against, so the # base the branch was cut from is observed instead. Probed only in that # case, so the published path's evidence is untouched (#772 AC8). @@ -2487,6 +2501,7 @@ def _evaluate_issue_lock_recovery( remote_branch_exists=remote_branch_exists, recorded_base_sha=recorded_base, base_ancestry=base_ancestry, + sync_provenance=sync_provenance, ) @@ -18889,10 +18904,59 @@ def gitea_update_pr_branch_by_merge( prepared_verdict_head_sha=live_pr_head, ) - return { - "success": True, + # #871: the remote head is now advanced; the durable linked-issue lock must + # be advanced with it, or a later dead-session recovery can never prove + # ownership at the new head. This runs AFTER the successful remote update, so + # a failure here is a *partial* lifecycle failure — the remote moved but the + # durable state did not — and must never be reported as a full success. + claimant = _work_lease_claimant(h) + matched_issue = ownership.get("matched_issue") + synced_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + lock_refresh: dict = { + "refreshed": False, + "reasons": ["durable lock head refresh was not attempted"], + } + if new_head and matched_issue and source_branch and (wt or None): + try: + lock_refresh = issue_lock_store.apply_durable_lock_head_refresh( + remote=remote, + org=o, + repo=r, + issue_number=int(matched_issue), + branch_name=source_branch, + worktree_path=wt, + pr_number=pr_number, + identity=claimant.get("username"), + profile=claimant.get("profile"), + current_pid=os.getpid(), + expected_old_head=live_pr_head, + new_head=new_head, + synced_at=synced_at, + base_head=live_base_head, + ) + except Exception as exc: + lock_refresh = { + "refreshed": False, + "reasons": [ + f"durable lock head refresh raised (fail closed): {_redact(str(exc))}" + ], + } + else: + lock_refresh = { + "refreshed": False, + "reasons": [ + "durable lock head refresh could not run: missing new head, " + "linked issue, source branch, or worktree binding" + ], + } + + durable_refreshed = bool(lock_refresh.get("refreshed")) + base_result = { "performed": True, "mutation_allowed": True, + "durable_lock_refreshed": durable_refreshed, + "durable_lock_refresh": lock_refresh, + "fully_synchronized": durable_refreshed, "style": "merge", "force_push": False, "rebase": False, @@ -18914,19 +18978,37 @@ def gitea_update_pr_branch_by_merge( "prepared_verdict_invalidated": transition.get( "prepared_verdict_invalidated" ), - "recommended_next_action": transition.get( - "recommended_next_action", - pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED, - ), "transition": transition, "role_kind": role, "profile_name": profile.get("profile_name"), "worktree_path": wt or None, - "reasons": list(transition.get("reasons") or []) + [ - "update-by-merge completed via native Gitea API (style=merge only)" - ], } + if durable_refreshed: + base_result["success"] = True + base_result["recommended_next_action"] = transition.get( + "recommended_next_action", + pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED, + ) + base_result["reasons"] = list(transition.get("reasons") or []) + [ + "update-by-merge completed via native Gitea API (style=merge only)", + f"durable linked-issue lock #{matched_issue} head refreshed to " + f"{new_head} (verified by read-after-write)", + ] + return base_result + + # Partial lifecycle failure: the remote advanced but the durable lock did + # not. Do NOT report a fully successful synchronization (#871). + base_result["success"] = False + base_result["partial_lifecycle_failure"] = True + base_result["recommended_next_action"] = pr_sync_status.ACTION_BLOCKED + base_result["reasons"] = list(lock_refresh.get("reasons") or []) + [ + f"PARTIAL LIFECYCLE FAILURE: PR #{pr_number} remote head advanced to " + f"{new_head} but the durable linked-issue lock head was not refreshed; " + "the synchronization is NOT complete", + ] + return base_result + @mcp.tool() def gitea_assess_conflict_fix_push( diff --git a/issue_lock_recovery.py b/issue_lock_recovery.py index ecc99c6..ae0cc35 100644 --- a/issue_lock_recovery.py +++ b/issue_lock_recovery.py @@ -85,6 +85,12 @@ HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant" # #772: an unpublished claim has no recorded head to compare against at all, so # its head is measured against the base the branch was cut from instead. HEAD_RELATION_DESCENDS_FROM_BASE = "descends_from_recorded_base" +# #871: the remote/PR head advanced *past* the recorded head via a sanctioned +# merge-based branch synchronization (``gitea_update_pr_branch_by_merge``) while +# the local worktree stayed at the recorded head. This is the inverse of the +# #768 descendant relation — here the *remote* strictly descends the local head, +# and only because a base was merged into the branch, proven server-side. +HEAD_RELATION_REMOTE_MERGE_SYNCED = "remote_merge_synced" # Which body of evidence a recovery was decided on (#772 AC10). These are not # interchangeable: a published claim proves ownership against a remote/PR head, @@ -266,6 +272,70 @@ def _assess_base_descendancy( ] +def _assess_remote_merge_synced( + sync_provenance: Mapping[str, Any] | None, + *, + recorded_head: str, + remote_head: str, +) -> tuple[bool, list[str]]: + """Did ``remote_head`` advance past ``recorded_head`` via a sanctioned + merge-based branch sync (#871)? + + ``sync_provenance`` is the server-side git observation from + ``issue_lock_worktree.read_merge_sync_provenance``. Its own + ``prior_head_sha`` / ``synced_head_sha`` are re-checked against the heads + this assessment is actually reasoning about, so an observation taken for some + other pair of commits — stale, mismatched, or hand-built — can never + authorize recovery. This is the inverse of ``_assess_strict_descendant``: the + recorded head is the ancestor and the *remote* head is the descendant, and it + is accepted only because the remote head is a base-into-branch merge that + preserved the branch mainline back to the recorded head. + + Returns ``(proven, notes)``. Notes name the exact missing element so a + refused caller sees why, never a bare "unproven". + """ + if not isinstance(sync_provenance, Mapping): + return False, [ + "no server-derived merge-sync provenance observation was available; a " + "remote head ahead of the recorded head cannot be accepted" + ] + + probe_prior = _text(sync_provenance.get("prior_head_sha")) + probe_synced = _text(sync_provenance.get("synced_head_sha")) + if probe_prior != recorded_head or probe_synced != remote_head: + return False, [ + f"merge-sync observation covers {probe_prior or 'unknown'} -> " + f"{probe_synced or 'unknown'}, not the heads under assessment " + f"({recorded_head} -> {remote_head})" + ] + if not sync_provenance.get("probe_ok"): + return False, ( + list(sync_provenance.get("reasons") or []) + or ["merge-sync provenance probe did not complete; provenance unproven"] + ) + if not sync_provenance.get("prior_is_ancestor"): + return False, [ + f"recorded head {recorded_head} is not an ancestor of remote head " + f"{remote_head}; a rewritten or force-moved head cannot be recovered" + ] + if not sync_provenance.get("is_merge_sync"): + return False, ( + list(sync_provenance.get("reasons") or []) + or [ + f"remote head {remote_head} is not a sanctioned merge-based sync " + f"of the base into the branch above {recorded_head}" + ] + ) + + proof = _text(sync_provenance.get("proof")) or ( + f"{remote_head} merged the base into the branch above {recorded_head}" + ) + return True, [ + f"remote head {remote_head} advanced past recorded head {recorded_head} " + f"via a sanctioned merge-based branch sync ({proof})" + ] + + def assess_dead_session_lock_recovery( existing_lock: Mapping[str, Any] | None, *, @@ -290,6 +360,7 @@ def assess_dead_session_lock_recovery( remote_branch_exists: bool | None = None, recorded_base_sha: str | None = None, base_ancestry: Mapping[str, Any] | None = None, + sync_provenance: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Decide whether a dead-session author lock may be natively recovered. @@ -469,19 +540,39 @@ def assess_dead_session_lock_recovery( head_relation = HEAD_RELATION_STRICT_DESCENDANT ancestry_proof = notes[0] if notes else None else: - reasons.append( - f"local head {local_head} does not match remote branch head " - f"{remote_head}" + # #871: the reverse relation — the remote head advanced past + # the recorded/local head via a sanctioned merge-based branch + # sync while the local worktree stayed put. Accepted only on + # server-proven merge-sync provenance, never a caller claim. + synced, sync_notes = _assess_remote_merge_synced( + sync_provenance, + recorded_head=local_head, + remote_head=remote_head, ) - reasons.extend(notes) + if synced: + head_relation = HEAD_RELATION_REMOTE_MERGE_SYNCED + ancestry_proof = sync_notes[0] if sync_notes else None + else: + reasons.append( + f"local head {local_head} does not match remote branch " + f"head {remote_head}" + ) + reasons.extend(notes) + reasons.extend(sync_notes) evidence["recorded_base"] = recorded_base or None evidence["local_head"] = local_head or None evidence["remote_head"] = remote_head or None # ``recorded_head`` is the head recovery is being measured against; # ``accepted_head`` is the head this recovery actually adopts. They differ # only in the descendant case, and downstream gates need both (#768 AC2/AC7). + # #871: in the merge-sync case the branch/PR already carries the synced + # remote head, so that is the head recovery adopts; the local worktree stays + # at the ancestor recorded head. evidence["recorded_head"] = remote_head or None - evidence["accepted_head"] = local_head or None + if head_relation == HEAD_RELATION_REMOTE_MERGE_SYNCED: + evidence["accepted_head"] = remote_head or None + else: + evidence["accepted_head"] = local_head or None evidence["head_relation"] = head_relation evidence["ancestry_proof"] = ancestry_proof @@ -493,12 +584,17 @@ def assess_dead_session_lock_recovery( # contradictory; re-stating it as a head mismatch would only obscure why. if not unpublished and local_head and pr_head != local_head: # A descendant recovery has not been published yet, so the open PR - # legitimately still points at the recorded head. Any other - # disagreement is a real mismatch. + # legitimately still points at the recorded head. A merge-sync + # recovery's PR legitimately sits at the advanced remote head. Any + # other disagreement is a real mismatch. if not ( head_relation == HEAD_RELATION_STRICT_DESCENDANT and remote_head and pr_head == remote_head + ) and not ( + head_relation == HEAD_RELATION_REMOTE_MERGE_SYNCED + and remote_head + and pr_head == remote_head ): reasons.append( f"open PR #{pr_number} head {pr_head} does not match local head " @@ -619,7 +715,11 @@ def assess_dead_session_lock_recovery( ) if ( head_relation - in (HEAD_RELATION_STRICT_DESCENDANT, HEAD_RELATION_DESCENDS_FROM_BASE) + in ( + HEAD_RELATION_STRICT_DESCENDANT, + HEAD_RELATION_DESCENDS_FROM_BASE, + HEAD_RELATION_REMOTE_MERGE_SYNCED, + ) and ancestry_proof ): proof.append(ancestry_proof) @@ -697,6 +797,16 @@ def owning_pr_recovery_evidence( return None if accepted_head != local_head: return None + elif relation == HEAD_RELATION_REMOTE_MERGE_SYNCED: + # #871: the PR already sits at the advanced remote head; the local + # worktree is the ancestor the merge preserved. The head the open PR + # shows and the head recovery adopts are both the synced remote head. + if not remote_head or pr_head != remote_head: + return None + if accepted_head != remote_head: + return None + if not local_head or local_head == remote_head: + return None else: return None try: @@ -765,6 +875,18 @@ def recovered_owning_pr_from_lock( return None if not accepted_head or accepted_head == recorded_head: return None + elif relation == HEAD_RELATION_REMOTE_MERGE_SYNCED: + # #871: PR sits at the advanced remote head, which is both the recorded + # measured-against head and the adopted head; the local worktree is the + # ancestor the merge preserved. + remote_head = _text(record.get("remote_head")) + local_head = _text(record.get("local_head")) + if not remote_head or pr_head != remote_head: + return None + if accepted_head and accepted_head != remote_head: + return None + if not local_head or local_head == remote_head: + return None else: return None try: diff --git a/issue_lock_store.py b/issue_lock_store.py index 713fa2a..a963174 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -737,4 +737,308 @@ def format_lock_proof( parts.append("lock released") elif released is False: parts.append("lock retained") - return "; ".join(parts) \ No newline at end of file + return "; ".join(parts) + + +# ── #871: durable linked-issue lock head refresh after branch synchronization ── +_FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +# Provenance recorded on the lock when the head is refreshed by a sanctioned +# merge-based branch synchronization (``gitea_update_pr_branch_by_merge``). +LOCK_HEAD_REFRESH_PROVENANCE_MERGE_SYNC = "gitea_update_pr_branch_by_merge" + + +def _norm_sha(value: Any) -> str | None: + text = str(value or "").strip().lower() + return text if _FULL_SHA_RE.match(text) else None + + +def _lock_claimant_view(lock: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(lock, dict): + return {} + claimant = lock.get("claimant") + if not isinstance(claimant, dict): + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, dict) else None + return dict(claimant) if isinstance(claimant, dict) else {} + + +def assess_durable_lock_head_refresh( + existing_lock: dict[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + pr_number: int | None, + identity: str | None, + profile: str | None, + current_pid: int | None, + expected_old_head: str | None, + new_head: str | None, + base_head: str | None = None, +) -> dict[str, Any]: + """Fail-closed assessment for refreshing a durable lock's recorded head (#871). + + A successful ``gitea_update_pr_branch_by_merge`` advances the *remote* PR head + but must also advance the durable linked-issue lock so a later dead-session + recovery can prove ownership. This decides whether that refresh is permitted; + it mutates nothing. + + Every element of durable ownership is re-verified against the persisted lock — + repository, issue, branch, worktree, claimant identity/profile, and the live + owning session — and the recorded head is compare-and-swapped: the lock's + currently recorded synced head (if any) must equal ``expected_old_head``, so a + lock whose head or provenance changed concurrently is never overwritten. + """ + reasons: list[str] = [] + old = _norm_sha(expected_old_head) + new = _norm_sha(new_head) + evidence: dict[str, Any] = { + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": worktree_path, + "pr_number": pr_number, + "expected_old_head": old, + "new_head": new, + "base_head": _norm_sha(base_head), + } + + if not isinstance(existing_lock, dict) or not existing_lock: + reasons.append("no durable lock exists for this issue; nothing to refresh") + return {"allowed": False, "reasons": reasons, "evidence": evidence, + "expected_generation": 0} + + lock = dict(existing_lock) + evidence["current_generation"] = lock_generation(lock) + + if lock.get("issue_number") != issue_number: + reasons.append( + f"durable lock targets issue #{lock.get('issue_number')}, not " + f"#{issue_number}; refusing head refresh" + ) + + for field, expected in (("remote", remote), ("org", org), ("repo", repo)): + actual = str(lock.get(field) or "").strip() + if actual != str(expected or "").strip(): + reasons.append( + f"lock {field} '{actual}' does not match requested " + f"'{str(expected or '').strip()}'" + ) + + locked_branch = str(lock.get("branch_name") or "").strip() + if locked_branch != str(branch_name or "").strip(): + reasons.append( + f"lock branch '{locked_branch}' does not match requested " + f"'{str(branch_name or '').strip()}'" + ) + + locked_worktree = str(lock.get("worktree_path") or "").strip() + try: + same_wt = bool(locked_worktree) and bool(worktree_path) and ( + os.path.realpath(locked_worktree) == os.path.realpath(worktree_path) + ) + except OSError: + same_wt = locked_worktree == (worktree_path or "") + if not same_wt: + reasons.append( + f"lock worktree '{locked_worktree}' does not match declared " + f"'{str(worktree_path or '').strip()}'" + ) + + claimant = _lock_claimant_view(lock) + locked_identity = str(claimant.get("username") or "").strip() + locked_profile = str(claimant.get("profile") or "").strip() + if not locked_identity or not locked_profile: + reasons.append( + "durable lock does not record a claimant identity/profile; " + "ownership could not be proven for head refresh" + ) + if not str(identity or "").strip() or not str(profile or "").strip(): + reasons.append( + "active session identity/profile is unknown; ownership could not be " + "proven for head refresh" + ) + if locked_identity and str(identity or "").strip() and locked_identity != str(identity).strip(): + reasons.append( + f"lock claimant '{locked_identity}' does not match active identity " + f"'{str(identity).strip()}'" + ) + if locked_profile and str(profile or "").strip() and locked_profile != str(profile).strip(): + reasons.append( + f"lock profile '{locked_profile}' does not match active profile " + f"'{str(profile).strip()}'" + ) + + # The refresh is written by the LIVE owning author session. A refresh is not + # a recovery: the current process must be the recorded owner. + recorded_pid = lock.get("session_pid") + if recorded_pid is None: + recorded_pid = lock.get("pid") + evidence["recorded_pid"] = recorded_pid + evidence["current_pid"] = current_pid + if current_pid is None: + reasons.append("current session pid is unknown; cannot prove live ownership") + else: + try: + if recorded_pid is None or int(recorded_pid) != int(current_pid): + reasons.append( + f"durable lock is owned by pid {recorded_pid}, not the current " + f"session pid {current_pid}; head refresh requires the live owner" + ) + except (TypeError, ValueError): + reasons.append( + "durable lock owner pid is malformed; cannot prove live ownership" + ) + + if not old: + reasons.append("expected_old_head is not a full 40-char hex SHA (fail closed)") + if not new: + reasons.append("new_head is not a full 40-char hex SHA (fail closed)") + if old and new and old == new: + reasons.append( + "new head equals the expected old head; a sync must advance the head" + ) + + # Compare-and-swap on the recorded head: if the lock already records a synced + # head it must be exactly the expected old head, else another sync moved it. + recorded_synced = _norm_sha(lock.get("synced_pr_head")) + evidence["recorded_synced_pr_head"] = recorded_synced + if recorded_synced is not None and old is not None and recorded_synced != old: + reasons.append( + f"durable lock already records synced head {recorded_synced}, not the " + f"expected old head {old}; a concurrent sync changed it (CAS fail closed)" + ) + + if reasons: + return {"allowed": False, "reasons": reasons, "evidence": evidence, + "expected_generation": lock_generation(lock)} + + return { + "allowed": True, + "reasons": [ + f"durable lock for issue #{issue_number} branch '{locked_branch}' is " + f"owned by the live session; refresh recorded head {old} -> {new}" + ], + "evidence": evidence, + "expected_generation": lock_generation(lock), + } + + +def apply_durable_lock_head_refresh( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + pr_number: int | None, + identity: str | None, + profile: str | None, + current_pid: int | None, + expected_old_head: str | None, + new_head: str | None, + synced_at: str, + base_head: str | None = None, + provenance: str = LOCK_HEAD_REFRESH_PROVENANCE_MERGE_SYNC, + lock_dir: str | None = None, +) -> dict[str, Any]: + """CAS-refresh the durable lock's recorded head after a branch sync (#871). + + Reads the durable lock from disk, re-asserts ownership via + ``assess_durable_lock_head_refresh``, and — only when permitted — writes the + new synced head through ``bind_session_lock`` with a generation compare-and- + swap. Then re-reads the lock and proves it records the complete new head + (read-after-write). Any failure at any step returns ``refreshed=False`` with + reasons; the caller must treat that as a partial lifecycle failure and never + report a fully successful synchronization. + """ + existing = load_issue_lock( + remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=lock_dir + ) + assessment = assess_durable_lock_head_refresh( + existing, + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + branch_name=branch_name, + worktree_path=worktree_path, + pr_number=pr_number, + identity=identity, + profile=profile, + current_pid=current_pid, + expected_old_head=expected_old_head, + new_head=new_head, + base_head=base_head, + ) + result: dict[str, Any] = { + "refreshed": False, + "read_after_write_ok": False, + "prior_head": _norm_sha(expected_old_head), + "new_head": _norm_sha(new_head), + "reasons": list(assessment.get("reasons") or []), + "evidence": assessment.get("evidence"), + } + if not assessment.get("allowed"): + return result + + new = _norm_sha(new_head) + old = _norm_sha(expected_old_head) + record = dict(existing or {}) + sync_block = { + "last_synced_pr_head": new, + "prior_pr_head": old, + "base_head": _norm_sha(base_head), + "pr_number": pr_number, + "provenance": provenance, + "synced_at": synced_at, + "synced_by_pid": current_pid, + "synced_by": { + "username": str(identity or "").strip() or None, + "profile": str(profile or "").strip() or None, + }, + } + record["synced_pr_head"] = new + record["branch_sync"] = sync_block + history = record.get("branch_sync_history") + if not isinstance(history, list): + history = [] + history = list(history) + history.append(sync_block) + record["branch_sync_history"] = history + + try: + bind_session_lock( + record, + lock_dir=lock_dir, + expected_generation=assessment.get("expected_generation"), + renewal_sanctioned=True, + ) + except Exception as exc: # CAS miss or write failure — partial lifecycle failure + result["reasons"].append( + f"durable lock head refresh write failed (fail closed): {exc}" + ) + return result + + after = load_issue_lock( + remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=lock_dir + ) + after_head = _norm_sha((after or {}).get("synced_pr_head")) + result["lock_generation_after"] = lock_generation(after) + if after_head == new and new is not None: + result["refreshed"] = True + result["read_after_write_ok"] = True + result["reasons"].append( + f"durable lock recorded head refreshed to {new} and verified by " + "read-after-write" + ) + else: + result["reasons"].append( + "read-after-write verification failed: durable lock does not record " + f"the new head {new} (found {after_head}); partial lifecycle failure" + ) + return result \ No newline at end of file diff --git a/issue_lock_worktree.py b/issue_lock_worktree.py index de52ff7..8188536 100644 --- a/issue_lock_worktree.py +++ b/issue_lock_worktree.py @@ -145,6 +145,175 @@ def read_head_ancestry( return result +def read_merge_sync_provenance( + worktree_path: str, + *, + prior_head_sha: str | None, + synced_head_sha: str | None, +) -> dict: + """Observe whether ``synced_head_sha`` is a sanctioned merge-based branch sync + that advanced the PR branch past ``prior_head_sha`` (#871). + + ``gitea_update_pr_branch_by_merge`` advances a PR branch by merging the base + branch *into* the branch (``POST /pulls/{n}/update?style=merge``). The result + is a merge commit ``M`` on the branch whose **first** parent is the prior + branch head and whose second parent is the base tip. When the owning session + then dies without the durable lock's recorded head being refreshed, the local + worktree still sits at ``prior_head_sha`` while the live PR head is ``M``. + + Recovering that drift safely requires proving the remote head is *exactly* + such a merge-sync — not a rewrite, rebase, force-push, or an unrelated + commit. This is that server-side observation. It reports facts only; the + disposition lives in ``issue_lock_recovery``. Every field is read from git in + the declared worktree — nothing is supplied by, or reachable from, an MCP + caller (#871). + + Provenance is proven only when ALL hold: + + * both commits are present (a rewritten/force-moved prior head leaves the + object graph and fails closed); + * ``prior_head_sha`` is a strict ancestor of ``synced_head_sha`` (the branch + history is preserved, never replaced); + * ``synced_head_sha`` is a merge commit (two or more parents), i.e. a base + merged in — a plain fast-forward of new direct commits is not a sync; + * ``prior_head_sha`` is an ancestor of the merge's **first** parent, so the + branch mainline (first-parent lineage) still reaches the prior head — a + rebase/force-push that re-authored the branch side fails this. + """ + path = (worktree_path or "").strip() + prior = (prior_head_sha or "").strip() + synced = (synced_head_sha or "").strip() + result: dict = { + "prior_head_sha": prior or None, + "synced_head_sha": synced or None, + "probe_ok": False, + "prior_present": False, + "synced_present": False, + "prior_is_ancestor": False, + "synced_is_merge": False, + "first_parent_reaches_prior": False, + "is_merge_sync": False, + "first_parent_sha": None, + "parent_count": None, + "proof": None, + "reasons": [], + } + if not path or not prior or not synced: + result["reasons"].append( + "merge-sync provenance probe requires a worktree path and both " + "commit SHAs" + ) + return result + if prior == synced: + result["reasons"].append( + "prior and synced heads are identical; no branch sync occurred" + ) + return result + + def _present(sha: str) -> bool: + res = subprocess.run( + ["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"], + capture_output=True, + text=True, + check=False, + ) + return res.returncode == 0 + + def _is_ancestor(ancestor: str, descendant: str) -> bool | None: + res = subprocess.run( + ["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant], + capture_output=True, + text=True, + check=False, + ) + if res.returncode == 0: + return True + if res.returncode == 1: + return False + return None # failed probe — never a silent "no" + + try: + result["prior_present"] = _present(prior) + result["synced_present"] = _present(synced) + except OSError as exc: # git unavailable — fail closed, never assume + result["reasons"].append(f"merge-sync provenance probe could not run: {exc}") + return result + + if not result["prior_present"]: + result["reasons"].append( + f"prior head {prior} is not reachable in '{path}'; history may have " + "been rewritten or force-moved" + ) + if not result["synced_present"]: + result["reasons"].append( + f"synced head {synced} is not reachable in '{path}'" + ) + if not (result["prior_present"] and result["synced_present"]): + return result + + ancestor = _is_ancestor(prior, synced) + if ancestor is None: + result["reasons"].append( + "ancestry probe failed; merge-sync provenance unproven" + ) + return result + result["prior_is_ancestor"] = bool(ancestor) + if not ancestor: + result["reasons"].append( + f"prior head {prior} is not an ancestor of synced head {synced}; " + "the branch history was not preserved (not a merge-based sync)" + ) + return result + + parents_res = subprocess.run( + ["git", "-C", path, "rev-list", "--parents", "-n", "1", synced], + capture_output=True, + text=True, + check=False, + ) + if parents_res.returncode != 0: + result["reasons"].append( + f"could not read parents of {synced}; merge-sync provenance unproven" + ) + return result + tokens = (parents_res.stdout or "").split() + # tokens[0] is the commit itself; the rest are its parents. + parents = tokens[1:] + result["parent_count"] = len(parents) + result["synced_is_merge"] = len(parents) >= 2 + if not result["synced_is_merge"]: + result["probe_ok"] = True + result["reasons"].append( + f"synced head {synced} has {len(parents)} parent(s); a merge-based " + "branch sync produces a merge commit (two or more parents)" + ) + return result + first_parent = parents[0] + result["first_parent_sha"] = first_parent + + fp_reaches = _is_ancestor(prior, first_parent) if prior != first_parent else True + if fp_reaches is None: + result["reasons"].append( + "first-parent ancestry probe failed; merge-sync provenance unproven" + ) + return result + result["first_parent_reaches_prior"] = bool(fp_reaches) + result["probe_ok"] = True + if not fp_reaches: + result["reasons"].append( + f"merge first parent {first_parent} does not reach prior head " + f"{prior}; the branch mainline was re-authored (not a sanctioned sync)" + ) + return result + + result["is_merge_sync"] = True + result["proof"] = ( + f"synced head {synced} is a merge commit (parents={len(parents)}) whose " + f"first-parent lineage reaches prior head {prior}; base merged into branch" + ) + return result + + def read_recorded_base( worktree_path: str, *, diff --git a/tests/test_issue_871_durable_lock_head_refresh.py b/tests/test_issue_871_durable_lock_head_refresh.py new file mode 100644 index 0000000..37851b1 --- /dev/null +++ b/tests/test_issue_871_durable_lock_head_refresh.py @@ -0,0 +1,630 @@ +"""Durable linked-issue lock head refresh + merge-sync dead-session recovery (#871). + +``gitea_update_pr_branch_by_merge`` advances a PR's *remote* head but historically +never advanced the linked durable issue lock's recorded head. After the owning +session died the drifted lock became unrecoverable and no further synchronization +was possible (PR #866 / issue #855). + +Two halves are covered: + +* the write-side refresh (``issue_lock_store.assess/apply_durable_lock_head_refresh``) + that records the new synced head under compare-and-swap with read-after-write; and +* the read-side recovery relation (``issue_lock_recovery`` + + ``issue_lock_worktree.read_merge_sync_provenance``) that lets a dead-session lock + whose recorded head is a merge-sync *ancestor* of the live PR head be recovered — + and nothing else. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_lock_recovery # noqa: E402 +import issue_lock_store # noqa: E402 +import issue_lock_worktree # noqa: E402 + +ISSUE = 8710 +PR_NUMBER = 8711 +BRANCH = f"fix/issue-{ISSUE}-durable-lock-head-refresh" +IDENTITY = "example-user" +PROFILE = "example-author" +OLD = "a" * 40 +NEW1 = "b" * 40 +NEW2 = "c" * 40 +BASE = "d" * 40 +REMOTE = "prgs" +ORG = "ExampleOrg" +REPO = "ExampleRepo" + + +def dead_pid() -> int: + proc = subprocess.Popen([sys.executable, "-c", "pass"]) + proc.wait() + return proc.pid + + +def future_ts(hours: int = 4) -> str: + return ( + (datetime.now(timezone.utc) + timedelta(hours=hours)) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _git(cwd, *args): + return subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + check=True, + ) + + +def _rev(cwd, ref="HEAD") -> str: + return _git(cwd, "rev-parse", ref).stdout.strip() + + +def build_merge_sync_repo(tmp: str) -> dict: + """Build a repo where a feature branch was synced by merging master in. + + Returns a dict with the prior (branch) head, the synced merge-commit head, + the master tip, plus a rebase-style linear descendant and an unrelated head. + """ + _git(tmp, "init", "-q", "-b", "master") + _git(tmp, "config", "user.email", "t@example.com") + _git(tmp, "config", "user.name", "T") + Path(tmp, "base.txt").write_text("base\n") + _git(tmp, "add", "-A") + _git(tmp, "commit", "-q", "-m", "root") + + # Feature branch cut from root, one commit — this is the PRIOR/recorded head. + _git(tmp, "checkout", "-q", "-b", BRANCH) + Path(tmp, "feature.txt").write_text("feature\n") + _git(tmp, "add", "-A") + _git(tmp, "commit", "-q", "-m", "feature work") + prior = _rev(tmp) + + # Master advances (the base the sync will merge in). + _git(tmp, "checkout", "-q", "master") + Path(tmp, "base.txt").write_text("base\nmore\n") + _git(tmp, "add", "-A") + _git(tmp, "commit", "-q", "-m", "master advance") + master_tip = _rev(tmp) + + # Sync: merge master INTO the feature branch → merge commit, first parent = prior. + _git(tmp, "checkout", "-q", BRANCH) + _git(tmp, "merge", "-q", "--no-ff", "-m", "Merge master into feature", "master") + synced = _rev(tmp) + + # A plain linear descendant of prior (NOT a merge) — a rebase/extra-commit shape. + _git(tmp, "checkout", "-q", "-b", "linear-branch", prior) + Path(tmp, "extra.txt").write_text("extra\n") + _git(tmp, "add", "-A") + _git(tmp, "commit", "-q", "-m", "extra linear commit") + linear = _rev(tmp) + + # An unrelated root (force-push / rewritten history shape). + unrelated_dir = tempfile.mkdtemp() + _git(unrelated_dir, "init", "-q", "-b", "x") + _git(unrelated_dir, "config", "user.email", "t@example.com") + _git(unrelated_dir, "config", "user.name", "T") + Path(unrelated_dir, "z.txt").write_text("z\n") + _git(unrelated_dir, "add", "-A") + _git(unrelated_dir, "commit", "-q", "-m", "unrelated") + unrelated = _rev(unrelated_dir) + + # Leave the worktree checked out on the feature branch at the PRIOR head, as + # a dead author session that never advanced would have left it. + _git(tmp, "checkout", "-q", BRANCH) + _git(tmp, "reset", "-q", "--hard", prior) + + return { + "prior": prior, + "master_tip": master_tip, + "synced": synced, + "linear": linear, + "unrelated": unrelated, + } + + +# ─────────────────────────── write-side refresh ─────────────────────────── + + +class TestDurableLockHeadRefresh(unittest.TestCase): + def setUp(self): + self.lock_dir = tempfile.mkdtemp() + self.wt = tempfile.mkdtemp() + lock_data = { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": self.wt, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + "work_lease": { + "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE, + "issue_number": ISSUE, + "branch": BRANCH, + "worktree_path": self.wt, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + "expires_at": future_ts(), + }, + } + issue_lock_store.bind_session_lock(lock_data, lock_dir=self.lock_dir) + + def _apply(self, **over): + kw = dict( + remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, + branch_name=BRANCH, worktree_path=self.wt, pr_number=PR_NUMBER, + identity=IDENTITY, profile=PROFILE, current_pid=os.getpid(), + expected_old_head=OLD, new_head=NEW1, synced_at=future_ts(0), + base_head=BASE, lock_dir=self.lock_dir, + ) + kw.update(over) + return issue_lock_store.apply_durable_lock_head_refresh(**kw) + + def _load(self): + return issue_lock_store.load_issue_lock( + remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, + lock_dir=self.lock_dir, + ) + + def test_first_sync_updates_recorded_head(self): + """AC1: first base sync writes the resulting head to the durable lock.""" + res = self._apply() + self.assertTrue(res["refreshed"], res["reasons"]) + self.assertTrue(res["read_after_write_ok"]) + self.assertEqual(self._load().get("synced_pr_head"), NEW1) + + def test_second_sync_after_master_advance(self): + """AC2: a later master advance permits a second sanctioned sync.""" + self.assertTrue(self._apply()["refreshed"]) + res2 = self._apply(expected_old_head=NEW1, new_head=NEW2) + self.assertTrue(res2["refreshed"], res2["reasons"]) + self.assertEqual(self._load().get("synced_pr_head"), NEW2) + history = self._load().get("branch_sync_history") + self.assertEqual(len(history), 2) + self.assertEqual(history[0]["last_synced_pr_head"], NEW1) + self.assertEqual(history[1]["prior_pr_head"], NEW1) + + def test_cas_detects_concurrent_head_change(self): + """AC6: CAS refuses when the recorded synced head is not the old head.""" + self.assertTrue(self._apply()["refreshed"]) # recorded head now NEW1 + # A second sync claiming the old head is still OLD must fail closed. + res = self._apply(expected_old_head=OLD, new_head=NEW2) + self.assertFalse(res["refreshed"]) + self.assertTrue(any("CAS" in r or "concurrent" in r for r in res["reasons"])) + self.assertEqual(self._load().get("synced_pr_head"), NEW1) + + def test_wrong_issue_fails_closed(self): + res = self._apply(issue_number=999999) + self.assertFalse(res["refreshed"]) + + def test_wrong_branch_fails_closed(self): + res = self._apply(branch_name="fix/issue-8710-wrong") + self.assertFalse(res["refreshed"]) + + def test_wrong_repo_fails_closed(self): + res = self._apply(repo="OtherRepo") + self.assertFalse(res["refreshed"]) + + def test_wrong_identity_fails_closed(self): + res = self._apply(identity="intruder") + self.assertFalse(res["refreshed"]) + + def test_wrong_profile_fails_closed(self): + res = self._apply(profile="prgs-reviewer") + self.assertFalse(res["refreshed"]) + + def test_foreign_session_fails_closed(self): + """A refresh is not a recovery: the current process must own the lock.""" + path = issue_lock_store.lock_file_path( + remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, + lock_dir=self.lock_dir, + ) + rec = issue_lock_store.read_lock_file(path) + rec["session_pid"] = dead_pid() + rec["pid"] = rec["session_pid"] + issue_lock_store.save_lock_file(path, rec) + res = self._apply() + self.assertFalse(res["refreshed"]) + self.assertTrue(any("current session" in r or "live owner" in r for r in res["reasons"])) + + def test_new_equals_old_fails_closed(self): + res = self._apply(expected_old_head=OLD, new_head=OLD) + self.assertFalse(res["refreshed"]) + + def test_non_full_sha_fails_closed(self): + self.assertFalse(self._apply(new_head="deadbeef")["refreshed"]) + self.assertFalse(self._apply(expected_old_head="xyz")["refreshed"]) + + def test_no_lock_fails_closed(self): + assessment = issue_lock_store.assess_durable_lock_head_refresh( + None, remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, + branch_name=BRANCH, worktree_path=self.wt, pr_number=PR_NUMBER, + identity=IDENTITY, profile=PROFILE, current_pid=os.getpid(), + expected_old_head=OLD, new_head=NEW1, + ) + self.assertFalse(assessment["allowed"]) + + +# ─────────────────────── merge-sync provenance (real git) ─────────────────── + + +class TestMergeSyncProvenanceObservation(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.shas = build_merge_sync_repo(self.tmp) + + def test_merge_sync_is_recognized(self): + obs = issue_lock_worktree.read_merge_sync_provenance( + self.tmp, prior_head_sha=self.shas["prior"], + synced_head_sha=self.shas["synced"], + ) + self.assertTrue(obs["is_merge_sync"], obs["reasons"]) + self.assertTrue(obs["prior_is_ancestor"]) + self.assertTrue(obs["synced_is_merge"]) + self.assertTrue(obs["first_parent_reaches_prior"]) + + def test_linear_descendant_is_not_a_merge_sync(self): + """A plain non-merge descendant (rebase/extra commit) is not a sync.""" + obs = issue_lock_worktree.read_merge_sync_provenance( + self.tmp, prior_head_sha=self.shas["prior"], + synced_head_sha=self.shas["linear"], + ) + self.assertTrue(obs["probe_ok"]) + self.assertFalse(obs["is_merge_sync"]) + self.assertFalse(obs["synced_is_merge"]) + + def test_unrelated_history_fails_closed(self): + """A rewritten/force-pushed head where prior is unreachable fails closed.""" + obs = issue_lock_worktree.read_merge_sync_provenance( + self.tmp, prior_head_sha=self.shas["prior"], + synced_head_sha=self.shas["unrelated"], + ) + self.assertFalse(obs["is_merge_sync"]) + + def test_missing_args_fail_closed(self): + obs = issue_lock_worktree.read_merge_sync_provenance( + self.tmp, prior_head_sha=None, synced_head_sha=self.shas["synced"], + ) + self.assertFalse(obs["is_merge_sync"]) + + +# ──────────────────── merge-sync dead-session recovery ────────────────────── + + +def make_dead_lock(worktree, **over): + pid = dead_pid() + lock = { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": worktree, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "session_pid": pid, + "pid": pid, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + "work_lease": { + "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE, + "issue_number": ISSUE, + "branch": BRANCH, + "worktree_path": worktree, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + "expires_at": future_ts(), + }, + } + lock.update(over) + return lock + + +def sync_prov(prior, synced, **over): + d = { + "prior_head_sha": prior, + "synced_head_sha": synced, + "probe_ok": True, + "prior_present": True, + "synced_present": True, + "prior_is_ancestor": True, + "synced_is_merge": True, + "first_parent_reaches_prior": True, + "is_merge_sync": True, + "first_parent_sha": prior, + "parent_count": 2, + "proof": f"{synced} merged base into branch above {prior}", + "reasons": [], + } + d.update(over) + return d + + +class TestMergeSyncRecovery(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.shas = build_merge_sync_repo(self.tmp) + self.prior = self.shas["prior"] + self.synced = self.shas["synced"] + + def _assess(self, **over): + lock = over.pop("_lock", None) or make_dead_lock(self.tmp) + kw = dict( + issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.tmp, + remote=REMOTE, org=ORG, repo=REPO, identity=IDENTITY, profile=PROFILE, + current_branch=BRANCH, porcelain_status="", + head_sha=self.prior, remote_head_sha=self.synced, + pr_head_sha=self.synced, pr_number=PR_NUMBER, + competing_live_locks=[], candidate_branches=[BRANCH], + current_pid=os.getpid(), + remote_branch_exists=True, + sync_provenance=sync_prov(self.prior, self.synced), + ) + kw.update(over) + return issue_lock_recovery.assess_dead_session_lock_recovery(lock, **kw) + + def test_merge_sync_drift_is_recoverable(self): + """AC3/AC4: dead session, recorded head is a merge-sync ancestor of PR head.""" + res = self._assess() + self.assertEqual(res["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED, res["reasons"]) + self.assertEqual( + res["evidence"]["head_relation"], + issue_lock_recovery.HEAD_RELATION_REMOTE_MERGE_SYNCED, + ) + self.assertEqual(res["evidence"]["accepted_head"], self.synced) + + def test_missing_provenance_fails_closed(self): + """No server-derived provenance → cannot accept a remote ahead of local.""" + res = self._assess(sync_provenance=None) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_non_ancestor_recorded_head_fails_closed(self): + """AC7: provenance that does not prove ancestry is rejected.""" + res = self._assess( + sync_provenance=sync_prov( + self.prior, self.synced, prior_is_ancestor=False, is_merge_sync=False, + reasons=["prior head is not an ancestor"], + ) + ) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_force_pushed_history_fails_closed(self): + """AC8: a rewritten head (not a merge sync) stays protected.""" + res = self._assess( + sync_provenance=sync_prov( + self.prior, self.synced, is_merge_sync=False, synced_is_merge=False, + reasons=["not a merge-based sync"], + ) + ) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_provenance_for_other_commits_fails_closed(self): + """Provenance whose endpoints differ from the heads under assessment is rejected.""" + res = self._assess( + sync_provenance=sync_prov("f" * 40, self.synced), + ) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_dirty_worktree_fails_closed(self): + """AC11: dirty worktrees remain protected.""" + res = self._assess(porcelain_status=" M feature.txt\n") + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_live_owner_fails_closed(self): + """AC10: a live recorded owner is not a dead-session recovery.""" + lock = make_dead_lock(self.tmp, session_pid=os.getpid(), pid=os.getpid()) + res = self._assess(_lock=lock) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_competing_claimant_fails_closed(self): + """AC13: a competing live lock blocks recovery.""" + res = self._assess( + competing_live_locks=[{ + "issue_number": ISSUE, "branch_name": BRANCH, + "worktree_path": "/some/other/wt", "pid": os.getpid(), + }] + ) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_wrong_branch_fails_closed(self): + """AC9: worktree on a different branch fails closed.""" + res = self._assess(current_branch="fix/issue-8710-other") + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_wrong_identity_fails_closed(self): + res = self._assess(identity="intruder") + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_pr_head_mismatch_fails_closed(self): + """The open PR must sit at the synced remote head.""" + res = self._assess(pr_head_sha="e" * 40) + self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED) + + def test_owning_pr_evidence_for_merge_sync(self): + res = self._assess() + ev = issue_lock_recovery.owning_pr_recovery_evidence(res) + self.assertIsNotNone(ev) + self.assertEqual(ev["pr_number"], PR_NUMBER) + self.assertEqual(ev["head_sha"], self.synced) + self.assertEqual( + ev["head_relation"], + issue_lock_recovery.HEAD_RELATION_REMOTE_MERGE_SYNCED, + ) + + def test_recovered_owning_pr_from_persisted_record(self): + res = self._assess() + record = issue_lock_recovery.build_recovery_record(res, recovered_at=future_ts(0)) + lock = {"issue_number": ISSUE, "branch_name": BRANCH, + "dead_session_recovery": record} + rebuilt = issue_lock_recovery.recovered_owning_pr_from_lock(lock) + self.assertIsNotNone(rebuilt) + self.assertEqual(rebuilt["head_sha"], self.synced) + self.assertEqual( + rebuilt["head_relation"], + issue_lock_recovery.HEAD_RELATION_REMOTE_MERGE_SYNCED, + ) + + +class TestExistingRelationsUnchanged(unittest.TestCase): + """AC14/AC15: equal-head recovery still works; merge-sync did not weaken it.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.shas = build_merge_sync_repo(self.tmp) + + def test_equal_head_recovery_still_sanctioned(self): + # Worktree at prior head; remote also at prior head → the #753 equal case. + prior = self.shas["prior"] + lock = make_dead_lock(self.tmp) + res = issue_lock_recovery.assess_dead_session_lock_recovery( + lock, issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.tmp, + remote=REMOTE, org=ORG, repo=REPO, identity=IDENTITY, profile=PROFILE, + current_branch=BRANCH, porcelain_status="", + head_sha=prior, remote_head_sha=prior, + pr_head_sha=prior, pr_number=PR_NUMBER, + competing_live_locks=[], candidate_branches=[BRANCH], + current_pid=os.getpid(), remote_branch_exists=True, + ) + self.assertEqual(res["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED, res["reasons"]) + self.assertEqual( + res["evidence"]["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL, + ) + + +class TestUpdatePrWrapperPartialFailure(unittest.TestCase): + """AC5/AC16: the tool advances the remote head then refreshes the durable lock. + + When the durable refresh fails after the remote advance, the tool must report a + partial lifecycle failure and NOT a fully successful synchronization. Exact PR- + head / base-head pinning is preserved (delegated to the real preflight, stubbed + here only to isolate the post-update lifecycle branch). + """ + + def setUp(self): + import gitea_mcp_server as gms # noqa: E402 + self.gms = gms + self._orig = {} + + def _patch(name, value): + self._orig[name] = getattr(gms, name) + setattr(gms, name, value) + + _patch("get_profile", lambda *a, **k: { + "allowed_operations": ["gitea.branch.push"], + "forbidden_operations": [], + "profile_name": "prgs-author", + }) + _patch("_role_kind", lambda *a, **k: "author") + _patch("_profile_operation_gate", lambda *a, **k: None) + _patch("_permission_block_report", lambda *a, **k: {}) + _patch("_resolve", lambda *a, **k: ("gitea.prgs.cc", ORG, REPO)) + _patch("_verify_role_mutation_workspace", lambda *a, **k: None) + _patch("_get_workspace_porcelain", lambda *a, **k: "") + _patch("_canonical_local_git_root", lambda *a, **k: "/x") + _patch("_master_parity_block", lambda *a, **k: None) + _patch("_auth", lambda *a, **k: {"token": "x"}) + _patch("repo_api_url", lambda *a, **k: "http://api") + _patch("_redact", lambda s: s) + _patch("_work_lease_claimant", lambda *a, **k: { + "username": IDENTITY, "profile": PROFILE, + }) + _patch("_prove_author_ownership_for_pr", lambda *a, **k: { + "has_author_lock": True, "matched_issue": ISSUE, + "matched_via": "branch", "linked_issues": [ISSUE], + "recovered_owning_pr": None, "reasons": [], + }) + + # Real preflight is unit-tested elsewhere; stub it to isolate the + # post-update durable-lock lifecycle branch under test. + orig_pf = gms.pr_sync_status.assess_update_pr_branch_preflight + self._orig_pf = orig_pf + gms.pr_sync_status.assess_update_pr_branch_preflight = ( + lambda *a, **k: {"mutation_allowed": True, "reasons": [], "performed": False} + ) + + # Sequence the two GET /pulls calls: OLD before update, NEW after. + self._pull_calls = {"n": 0} + + def fake_api_request(method, url, auth, *a, **k): + m = method.upper() + if m == "GET" and url.endswith(f"/pulls/{PR_NUMBER}"): + self._pull_calls["n"] += 1 + head = OLD if self._pull_calls["n"] == 1 else NEW1 + return { + "state": "open", + "head": {"sha": head, "ref": BRANCH}, + "base": {"sha": BASE, "ref": "master"}, + "mergeable": True, "title": "t", "body": "b", + } + if m == "GET" and "/branches/" in url: + return {"commit": {"id": BASE}} + if m == "POST" and "/update" in url: + return {} + return {} + + _patch("api_request", fake_api_request) + + def tearDown(self): + for name, value in self._orig.items(): + setattr(self.gms, name, value) + self.gms.pr_sync_status.assess_update_pr_branch_preflight = self._orig_pf + + def _run(self): + return self.gms.gitea_update_pr_branch_by_merge( + pr_number=PR_NUMBER, + expected_pr_head_sha=OLD, + expected_base_head_sha=BASE, + remote=REMOTE, + worktree_path="/tmp/branches/wt-871", + ) + + def test_partial_failure_when_refresh_fails(self): + self._orig["apply_durable_lock_head_refresh"] = ( + self.gms.issue_lock_store.apply_durable_lock_head_refresh + ) + self.gms.issue_lock_store.apply_durable_lock_head_refresh = ( + lambda **k: {"refreshed": False, "reasons": ["forced refresh failure"]} + ) + try: + res = self._run() + finally: + self.gms.issue_lock_store.apply_durable_lock_head_refresh = ( + self._orig["apply_durable_lock_head_refresh"] + ) + self.assertTrue(res["performed"]) + self.assertEqual(res["new_pr_head_sha"], NEW1) + self.assertFalse(res["success"]) + self.assertTrue(res["partial_lifecycle_failure"]) + self.assertFalse(res["durable_lock_refreshed"]) + + def test_full_success_when_refresh_succeeds(self): + self._orig["apply_durable_lock_head_refresh"] = ( + self.gms.issue_lock_store.apply_durable_lock_head_refresh + ) + self.gms.issue_lock_store.apply_durable_lock_head_refresh = ( + lambda **k: {"refreshed": True, "read_after_write_ok": True, + "new_head": NEW1, "reasons": ["ok"]} + ) + try: + res = self._run() + finally: + self.gms.issue_lock_store.apply_durable_lock_head_refresh = ( + self._orig["apply_durable_lock_head_refresh"] + ) + self.assertTrue(res["success"]) + self.assertTrue(res["performed"]) + self.assertTrue(res["durable_lock_refreshed"]) + self.assertTrue(res["fully_synchronized"]) + self.assertEqual(res["new_pr_head_sha"], NEW1) + + +if __name__ == "__main__": + unittest.main()