Compare commits
181
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c74b8da400 | ||
|
|
5deb66c7f6 | ||
|
|
870843f999 | ||
|
|
6862049ef6 | ||
|
|
9b5289940c | ||
|
|
64e6d7b7df | ||
|
|
06c476b37d | ||
|
|
42657b3b65 | ||
|
|
35714258f0 | ||
|
|
4e269f3a7a | ||
|
|
87c30484aa | ||
|
|
36fe4785ec | ||
|
|
1232789b41 | ||
|
|
2976c21ee6 | ||
|
|
2602605c83 | ||
|
|
ae31e1e852 | ||
|
|
572a3cf8b8 | ||
|
|
22f9547fd8 | ||
|
|
2e4ed38c51 | ||
|
|
a7a283f449 | ||
|
|
e42756b27f | ||
|
|
6f74552617 | ||
|
|
f7ef719bd6 | ||
|
|
38506a753f | ||
|
|
0987f93c67 | ||
|
|
103d0df289 | ||
|
|
b179610e7f | ||
|
|
cad5e44703 | ||
|
|
4b436ea7d0 | ||
|
|
8ad3641fd3 | ||
|
|
7e18dccbd9 | ||
|
|
205207abb0 | ||
|
|
0041542fe2 | ||
|
|
a87a7d1da2 | ||
|
|
bc54effbd0 | ||
|
|
c4d089f931 | ||
|
|
9504fa8bbd | ||
|
|
18bca47977 | ||
|
|
1f144705e2 | ||
|
|
e1d844bfed | ||
|
|
06e95254f0 | ||
|
|
b6a8989c77 | ||
|
|
3acfb039a9 | ||
|
|
a58b5d6e69 | ||
|
|
5e935dffb4 | ||
|
|
5c5c1fdf77 | ||
|
|
baf3a474df | ||
|
|
0ae05cb9bc | ||
|
|
82464f4054 | ||
|
|
2a5d6571ec | ||
|
|
c33c69b3f3 | ||
|
|
37c3e5dc39 | ||
|
|
784369cc25 | ||
|
|
2baf726ee6 | ||
|
|
67b4889984 | ||
|
|
e0b87a0ae5 | ||
|
|
34173e079c | ||
|
|
d2eaca4949 | ||
|
|
fd558ce5d8 | ||
|
|
ae1161524d | ||
|
|
d456a763fa | ||
|
|
cc56aeeacd | ||
|
|
44fe8d2eed | ||
|
|
d03d982e3b | ||
|
|
657b5bc1b3 | ||
|
|
b6ca778cef | ||
|
|
73f82a2305 | ||
|
|
cc7dc8ac14 | ||
|
|
e151759212 | ||
|
|
60df5087e9 | ||
|
|
78e3befbbb | ||
|
|
d7e69fbe77 | ||
|
|
4ff4d2acc9 | ||
|
|
c9aa09f341 | ||
|
|
b4afc8cefd | ||
|
|
0088ecaf00 | ||
|
|
e0536d344f | ||
|
|
467e35504c | ||
|
|
bf5c72e3ba | ||
|
|
5d59c57c98 | ||
|
|
a54b16676a | ||
|
|
2d0d8a682b | ||
|
|
eb35c75514 | ||
|
|
89657a06c4 | ||
|
|
d542b08ced | ||
|
|
499b87c482 | ||
|
|
ba3ea3012c | ||
|
|
3428fb4190 | ||
|
|
ef14622ba0 | ||
|
|
24c52abf6b | ||
|
|
a3f8f67c93 | ||
|
|
0b60fd6557 | ||
|
|
fc8fe329d2 | ||
|
|
e593444eea | ||
|
|
6d0015cabc | ||
|
|
67cd2da561 | ||
|
|
18d6583e83 | ||
|
|
fe259e6d38 | ||
|
|
f80e3b33b0 | ||
|
|
dc99c15ffa | ||
|
|
e9f6d68bd7 | ||
|
|
3a0d9e24ea | ||
|
|
5adc328a2b | ||
|
|
6a636c58e7 | ||
|
|
9301739910 | ||
|
|
b3859f6dad | ||
|
|
dc0a05e5e9 | ||
|
|
9cca5f3dd2 | ||
|
|
d2fe0110a0 | ||
|
|
edd5f813b2 | ||
|
|
9f759150b8 | ||
|
|
347464a057 | ||
|
|
1301a57de4 | ||
|
|
3b2b4e1dca | ||
|
|
b9ba43a5bf | ||
|
|
a1e5a4af8c | ||
|
|
188e83c4d6 | ||
|
|
db5ed6042b | ||
|
|
a942afe6c4 | ||
|
|
0254a99336 | ||
|
|
15c75d2225 | ||
|
|
8b34f9da0a | ||
|
|
2d95e0fcc6 | ||
|
|
8ba1c5b87c | ||
|
|
df58b5fb90 | ||
|
|
ecda200180 | ||
|
|
b70d5f3efa | ||
|
|
fa6ba8a162 | ||
|
|
a20975688d | ||
|
|
25bc2a3291 | ||
|
|
f1e4809930 | ||
|
|
0752b5d242 | ||
|
|
c040bd4674 | ||
|
|
f0c9ffb25e | ||
|
|
04d9df559e | ||
|
|
79256f9093 | ||
|
|
1c455b6ec0 | ||
|
|
c3f282ba44 | ||
|
|
8a63476787 | ||
|
|
5eb89f8830 | ||
|
|
a6c15afec1 | ||
|
|
dc1d0e045f | ||
|
|
6d4a0d12ec | ||
|
|
6868b345ee | ||
|
|
64b6eb5d54 | ||
|
|
4f3a464a90 | ||
|
|
f21f81f9b5 | ||
|
|
badc4e636b | ||
|
|
da6a864463 | ||
|
|
9468dd624d | ||
|
|
648d9464ba | ||
|
|
caaae9b6ee | ||
|
|
689c60fc7c | ||
|
|
66a89a46bb | ||
|
|
b7a63a5579 | ||
|
|
53c2c92782 | ||
|
|
14c9c4d702 | ||
|
|
08061b7b8a | ||
|
|
95a5eb254f | ||
|
|
910b6edbdc | ||
|
|
99fda93bcc | ||
|
|
5494696227 | ||
|
|
b2f6e9a6dc | ||
|
|
479e434f92 | ||
|
|
9eb0f29cef | ||
|
|
0b29404031 | ||
|
|
5463f58933 | ||
|
|
5032965e3a | ||
|
|
57a52b1a99 | ||
|
|
e33b8d3712 | ||
|
|
344dc41ce2 | ||
|
|
c1d2bad901 | ||
|
|
243f52dc79 | ||
|
|
620ed6e9a9 | ||
|
|
a30a3ce4c3 | ||
|
|
1a97ced133 | ||
|
|
3d0c13fa5a | ||
|
|
0f19773076 | ||
|
|
aa4fe1cc7b | ||
|
|
6b58f04d39 | ||
|
|
324b4b3e93 |
+468
-43
@@ -53,6 +53,8 @@ OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||
# #776: controller-supplied pre-rank exclusion.
|
||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||
# #844: epic / child-only implementation container (pre-rank).
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER = "epic_or_child_only_container"
|
||||
|
||||
# Ownership verdicts for a live claim on a candidate (#765).
|
||||
OWNERSHIP_OWN = "own"
|
||||
@@ -78,6 +80,33 @@ VALID_ROLES = frozenset(
|
||||
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
||||
)
|
||||
|
||||
# Allocation modes (#840).
|
||||
# role_scoped: only candidates whose expected role matches the caller role.
|
||||
# cross_role: controller-owned generic queue selection — inspect full queue,
|
||||
# rank/eligibility canonically, return one selection naming the required
|
||||
# downstream role/profile. Controller routes; it does not perform mutations.
|
||||
ALLOCATION_MODE_ROLE_SCOPED = "role_scoped"
|
||||
ALLOCATION_MODE_CROSS_ROLE = "cross_role"
|
||||
VALID_ALLOCATION_MODES = frozenset(
|
||||
{ALLOCATION_MODE_ROLE_SCOPED, ALLOCATION_MODE_CROSS_ROLE}
|
||||
)
|
||||
|
||||
# Default execution-profile / MCP-namespace names for each role.
|
||||
DEFAULT_ROLE_PROFILES: dict[str, str] = {
|
||||
ROLE_AUTHOR: "prgs-author",
|
||||
ROLE_REVIEWER: "prgs-reviewer",
|
||||
ROLE_MERGER: "prgs-merger",
|
||||
ROLE_RECONCILER: "prgs-reconciler",
|
||||
ROLE_CONTROLLER: "prgs-controller",
|
||||
}
|
||||
DEFAULT_ROLE_NAMESPACES: dict[str, str] = {
|
||||
ROLE_AUTHOR: "gitea-author",
|
||||
ROLE_REVIEWER: "gitea-reviewer",
|
||||
ROLE_MERGER: "gitea-merger",
|
||||
ROLE_RECONCILER: "gitea-reconciler",
|
||||
ROLE_CONTROLLER: "gitea-controller",
|
||||
}
|
||||
|
||||
# Default action matrices by role (mutation gate will re-check).
|
||||
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
ROLE_AUTHOR: (
|
||||
@@ -103,6 +132,39 @@ ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
}
|
||||
|
||||
|
||||
# Body phrases that prove an issue is an implementation container, not a
|
||||
# unit of direct author work (#844). Matched case-insensitively against the
|
||||
# issue body. Title alone is never sufficient (ordinary issues may mention
|
||||
# "epic" incidentally).
|
||||
_CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = (
|
||||
"implementation is delivered via child issues only",
|
||||
"implementation is delivered through child issues only",
|
||||
"implementation is delivered via child issues",
|
||||
"implementation is delivered through child issues",
|
||||
"do not implement product features in this epic",
|
||||
"do not implement product features in this epic issue itself",
|
||||
"no product feature implementation is claimed complete solely on this epic",
|
||||
"implementable child issues remain independently eligible",
|
||||
"owns the product roadmap and linkage",
|
||||
"this epic owns the product roadmap",
|
||||
"coordination container",
|
||||
"child-only container",
|
||||
"implementation is delegated to child",
|
||||
)
|
||||
|
||||
# Explicit epic / umbrella labels (structured evidence preferred over title).
|
||||
_EPIC_LABELS: frozenset[str] = frozenset(
|
||||
{
|
||||
"type:epic",
|
||||
"epic",
|
||||
"kind:epic",
|
||||
"scope:epic",
|
||||
"type:umbrella",
|
||||
"umbrella",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkCandidate:
|
||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||
@@ -112,6 +174,7 @@ class WorkCandidate:
|
||||
state: str = "open"
|
||||
labels: tuple[str, ...] = ()
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
priority: int = 0
|
||||
head_sha: str | None = None
|
||||
# Routing signals (callers derive from Gitea / review feedback).
|
||||
@@ -131,6 +194,7 @@ class WorkCandidate:
|
||||
self.labels = tuple(
|
||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||
)
|
||||
self.body = str(self.body or "")
|
||||
if self.kind not in WORK_KINDS:
|
||||
raise InvalidWorkKindError(
|
||||
f"candidate kind '{self.kind}' is not assignable; only "
|
||||
@@ -144,6 +208,7 @@ class WorkCandidate:
|
||||
"state": self.state,
|
||||
"labels": list(self.labels),
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"priority": self.priority,
|
||||
"head_sha": self.head_sha,
|
||||
"request_changes_current_head": self.request_changes_current_head,
|
||||
@@ -157,6 +222,51 @@ class WorkCandidate:
|
||||
}
|
||||
|
||||
|
||||
def classify_epic_or_child_only_container(
|
||||
c: WorkCandidate,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Return whether *c* is an epic / child-only implementation container (#844).
|
||||
|
||||
Exclusion uses structured evidence first (labels, body scope language).
|
||||
A bare title containing the word "epic" is **not** enough — ordinary
|
||||
implementable issues may mention epics incidentally. A title that is
|
||||
explicitly prefixed ``Epic:`` only counts when the body also proves
|
||||
child-only / no-direct-implementation scope (or an epic label is present).
|
||||
|
||||
PRs are never classified as containers here (they already have a head).
|
||||
"""
|
||||
if c.kind != "issue":
|
||||
return False, None
|
||||
|
||||
labels = set(c.labels)
|
||||
epic_label = sorted(labels & _EPIC_LABELS)
|
||||
body_l = (c.body or "").lower()
|
||||
title = (c.title or "").strip()
|
||||
title_l = title.lower()
|
||||
|
||||
body_hits = [m for m in _CHILD_ONLY_BODY_MARKERS if m in body_l]
|
||||
title_epic_prefix = title_l.startswith("epic:") or title_l.startswith("epic ")
|
||||
|
||||
if epic_label:
|
||||
detail = f"label={epic_label[0]}"
|
||||
if body_hits:
|
||||
detail = f"{detail}; body_marker={body_hits[0]!r}"
|
||||
return True, detail
|
||||
|
||||
if body_hits:
|
||||
# Body proves child-only / umbrella scope. Title "Epic:" is corroborating
|
||||
# but not required — containers without the word still exclude.
|
||||
detail = f"body_marker={body_hits[0]!r}"
|
||||
if title_epic_prefix:
|
||||
detail = f"title_epic_prefix; {detail}"
|
||||
return True, detail
|
||||
|
||||
# Title-only "Epic:" without body scope evidence is insufficient (#844 AC:
|
||||
# eligibility does not rely solely on the word "Epic" in a title).
|
||||
# Similarly, incidental "epic" mid-title without markers stays eligible.
|
||||
return False, None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkipRecord:
|
||||
kind: str
|
||||
@@ -259,6 +369,126 @@ def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
)
|
||||
|
||||
|
||||
def resolve_allocation_mode(
|
||||
role: str,
|
||||
allocation_mode: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve allocation mode; controller defaults to cross_role (#840)."""
|
||||
raw = (allocation_mode or "").strip().lower()
|
||||
if raw:
|
||||
if raw not in VALID_ALLOCATION_MODES:
|
||||
raise ControlPlaneError(
|
||||
f"unknown allocation_mode {allocation_mode!r}; expected one of "
|
||||
f"{sorted(VALID_ALLOCATION_MODES)}"
|
||||
)
|
||||
return raw
|
||||
if role == ROLE_CONTROLLER:
|
||||
return ALLOCATION_MODE_CROSS_ROLE
|
||||
return ALLOCATION_MODE_ROLE_SCOPED
|
||||
|
||||
|
||||
def required_profile_for_role(
|
||||
role: str,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map a required role to the canonical execution profile name."""
|
||||
role_norm = (role or "").strip().lower()
|
||||
# Preserve remote/env prefix from the active profile when present
|
||||
# (e.g. dadeschools-author → dadeschools-reviewer).
|
||||
active = (profile_name or "").strip()
|
||||
if active:
|
||||
lower = active.lower()
|
||||
for token in ("author", "reviewer", "merger", "reconciler", "controller"):
|
||||
if lower.endswith(f"-{token}") or lower == token:
|
||||
prefix = active[: -len(token)].rstrip("-")
|
||||
if prefix:
|
||||
return f"{prefix}-{role_norm}"
|
||||
return role_norm
|
||||
return DEFAULT_ROLE_PROFILES.get(role_norm, f"prgs-{role_norm}")
|
||||
|
||||
|
||||
def required_namespace_for_role(
|
||||
role: str,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map a required role to the canonical MCP namespace name."""
|
||||
role_norm = (role or "").strip().lower()
|
||||
profile = required_profile_for_role(role_norm, profile_name=profile_name)
|
||||
# Namespace is typically gitea-<role>; keep stable mapping when profile is
|
||||
# non-prgs (still gitea-<role> for isolation).
|
||||
return DEFAULT_ROLE_NAMESPACES.get(role_norm, f"gitea-{role_norm}")
|
||||
|
||||
|
||||
def selected_action_for_candidate(c: WorkCandidate, required_role: str) -> str:
|
||||
"""Canonical next action for the selected work under *required_role*."""
|
||||
role = (required_role or "").strip().lower()
|
||||
if role == ROLE_AUTHOR:
|
||||
if c.kind == "pr" and c.request_changes_current_head:
|
||||
return "address_pr_change_requests"
|
||||
if c.kind == "pr":
|
||||
return "update_pr"
|
||||
return "implement"
|
||||
if role == ROLE_REVIEWER:
|
||||
if c.approval_stale:
|
||||
return "re_review"
|
||||
return "review"
|
||||
if role == ROLE_MERGER:
|
||||
return "merge"
|
||||
if role == ROLE_RECONCILER:
|
||||
if c.approval_contaminated:
|
||||
return "reconcile_contaminated_approval"
|
||||
return "reconcile"
|
||||
if role == ROLE_CONTROLLER:
|
||||
return "diagnose"
|
||||
return "process"
|
||||
|
||||
|
||||
def build_selection_dict(
|
||||
selected: WorkCandidate,
|
||||
*,
|
||||
active_role: str,
|
||||
required_role: str,
|
||||
profile_name: str | None = None,
|
||||
allocation_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Authoritative single selection payload for allocator results (#840)."""
|
||||
action = selected_action_for_candidate(selected, required_role)
|
||||
req_profile = required_profile_for_role(
|
||||
required_role, profile_name=profile_name
|
||||
)
|
||||
req_ns = required_namespace_for_role(
|
||||
required_role, profile_name=profile_name
|
||||
)
|
||||
return {
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"title": selected.title,
|
||||
"labels": list(selected.labels),
|
||||
"head_sha": selected.head_sha,
|
||||
"priority": selected.priority,
|
||||
"expected_role_next": required_role,
|
||||
"required_role": required_role,
|
||||
"selected_action": action,
|
||||
"action": action,
|
||||
"required_profile": req_profile,
|
||||
"required_namespace": req_ns,
|
||||
"pinned": {
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"head_sha": selected.head_sha,
|
||||
"issue_number": selected.number if selected.kind == "issue" else None,
|
||||
"pr_number": selected.number if selected.kind == "pr" else None,
|
||||
},
|
||||
"reason_selected": (
|
||||
f"highest-priority eligible candidate under allocation_mode="
|
||||
f"'{allocation_mode}' (active_role={active_role}, "
|
||||
f"required_role={required_role}, action={action})"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
||||
"""ADR §5.3 routing: which role should take this work next."""
|
||||
if c.kind == "pr":
|
||||
@@ -289,6 +519,7 @@ def classify_skip(
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
claim_ownership: str | None = None,
|
||||
allocation_mode: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*.
|
||||
|
||||
@@ -297,7 +528,12 @@ def classify_skip(
|
||||
and unknown claims are excluded so one session's in-progress task can never
|
||||
blockade the queue for a different controller; ``own`` stays selectable so
|
||||
a controller can resume its own work.
|
||||
|
||||
*allocation_mode* (#840): ``cross_role`` (controller default) ranks the full
|
||||
queue and selects the highest-priority eligible item for any downstream
|
||||
role. ``role_scoped`` retains prior role-match filtering.
|
||||
"""
|
||||
mode = resolve_allocation_mode(role, allocation_mode)
|
||||
if c.state in ("merged", "closed"):
|
||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||
if c.blocked or "status:blocked" in c.labels:
|
||||
@@ -322,34 +558,58 @@ def classify_skip(
|
||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||
return f"pr#{c.number} missing head_sha pin"
|
||||
|
||||
expected = expected_role_for_candidate(c)
|
||||
|
||||
# Terminal path first: when an active terminal PR exists, only that PR
|
||||
# (or controller diagnosis) is assignable for review-path roles.
|
||||
# is assignable for review-path roles (or for work whose expected role is
|
||||
# review/merge under cross_role selection).
|
||||
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
||||
if role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
terminal_roles = (ROLE_REVIEWER, ROLE_MERGER)
|
||||
if mode == ALLOCATION_MODE_CROSS_ROLE:
|
||||
if expected in terminal_roles:
|
||||
return (
|
||||
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||
f"PR #{terminal_pr} must be resolved first"
|
||||
)
|
||||
elif role in terminal_roles:
|
||||
return (
|
||||
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||
f"PR #{terminal_pr} must be resolved first"
|
||||
)
|
||||
|
||||
expected = expected_role_for_candidate(c)
|
||||
if role == ROLE_CONTROLLER:
|
||||
# Controller may inspect anything but only assigns diagnosis targets
|
||||
# when contaminated / blocked.
|
||||
if mode == ALLOCATION_MODE_CROSS_ROLE:
|
||||
# Cross-role controller selection: eligibility only — no active-role
|
||||
# match filter. The selection payload names required_role.
|
||||
pass
|
||||
elif role == ROLE_CONTROLLER:
|
||||
# Legacy diagnosis-only controller path (role_scoped): only reconciler-
|
||||
# needed targets. Prefer cross_role for generic queue allocation.
|
||||
if expected == ROLE_RECONCILER or c.blocked:
|
||||
return None
|
||||
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
|
||||
|
||||
if role != expected:
|
||||
return (
|
||||
f"{c.kind}#{c.number} does not require controller "
|
||||
f"(expected {expected})"
|
||||
)
|
||||
elif role != expected:
|
||||
return (
|
||||
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
||||
)
|
||||
|
||||
# Ready-gate for issues: prefer status:ready when labels present.
|
||||
# Applies for author-bound work in both modes (cross_role only gates
|
||||
# author-expected issues so reconciler/reviewer PRs stay selectable).
|
||||
if c.kind == "issue" and c.labels:
|
||||
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
|
||||
# Allow unlabeled open issues; only skip explicit non-ready states.
|
||||
if any(l.startswith("status:") for l in c.labels):
|
||||
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
|
||||
gate_role = expected if mode == ALLOCATION_MODE_CROSS_ROLE else role
|
||||
if gate_role in (ROLE_AUTHOR, ROLE_CONTROLLER):
|
||||
if (
|
||||
"status:ready" not in c.labels
|
||||
and "status:in-progress" not in c.labels
|
||||
):
|
||||
if any(l.startswith("status:") for l in c.labels):
|
||||
return (
|
||||
f"issue#{c.number} not status:ready "
|
||||
f"({','.join(c.labels)})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -501,12 +761,19 @@ def allocate_next_work(
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
expected_candidate_set_fingerprint: str | None = None,
|
||||
allocation_mode: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||
|
||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||
|
||||
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
||||
the complete queue and returns one authoritative selection naming the
|
||||
required downstream role/profile/action. ``role_scoped`` keeps prior
|
||||
per-role filtering. Controller routes only — never grants author/reviewer/
|
||||
merger/reconciler mutation rights to the controller session.
|
||||
|
||||
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||
empty preserves prior behavior.
|
||||
|
||||
@@ -541,6 +808,19 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
try:
|
||||
mode = resolve_allocation_mode(role_norm, allocation_mode)
|
||||
except ControlPlaneError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_ROLE_INELIGIBLE,
|
||||
"reasons": [str(exc)],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"allocation_mode": (allocation_mode or "").strip() or None,
|
||||
}
|
||||
|
||||
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||
try:
|
||||
db.upsert_session(
|
||||
@@ -653,7 +933,8 @@ def allocate_next_work(
|
||||
ownership_defects: list[dict[str, Any]] = []
|
||||
controller_excluded: list[dict[str, Any]] = []
|
||||
|
||||
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||
# #776 AC2 + #844: remove excluded numbers *and* epic/child-only containers
|
||||
# *before* ranking / selection / lease so they never receive assignments.
|
||||
rankable: list[WorkCandidate] = []
|
||||
for c in candidates:
|
||||
if int(c.number) in exclude_set:
|
||||
@@ -732,6 +1013,23 @@ def allocate_next_work(
|
||||
},
|
||||
}
|
||||
continue
|
||||
# #844: epics / child-only containers are never direct implement targets.
|
||||
is_container, container_detail = classify_epic_or_child_only_container(c)
|
||||
if is_container:
|
||||
detail = container_detail or "epic or child-only container"
|
||||
reason = (
|
||||
f"{c.kind}#{c.number} {SKIP_EPIC_OR_CHILD_ONLY_CONTAINER}: "
|
||||
f"{detail}; implementation is delegated to child issues"
|
||||
)
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
||||
)
|
||||
)
|
||||
continue
|
||||
rankable.append(c)
|
||||
|
||||
ordered = sort_candidates(rankable)
|
||||
@@ -748,6 +1046,7 @@ def allocate_next_work(
|
||||
role=role_norm,
|
||||
terminal_pr=terminal_pr,
|
||||
claim_ownership=ownership,
|
||||
allocation_mode=mode,
|
||||
)
|
||||
if reason:
|
||||
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
||||
@@ -828,6 +1127,10 @@ def allocate_next_work(
|
||||
"outcome": outcome,
|
||||
"apply": bool(apply),
|
||||
"role": role_norm,
|
||||
"allocation_mode": mode,
|
||||
"routing_role": role_norm,
|
||||
"required_role": None,
|
||||
"selected_action": None,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
@@ -840,6 +1143,12 @@ def allocate_next_work(
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"allocation_evidence": {
|
||||
"mode": "empty",
|
||||
"allocation_mode": mode,
|
||||
"lease_created": False,
|
||||
"selection_policy": SELECTION_POLICY,
|
||||
},
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
@@ -852,25 +1161,37 @@ def allocate_next_work(
|
||||
"owner_session_id": owner_session_id,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
"allocator never assigns raw monitoring incidents; "
|
||||
"controller routes only under cross_role (#840)"
|
||||
),
|
||||
}
|
||||
|
||||
expected_role = expected_role_for_candidate(selected)
|
||||
allowed, forbidden = role_actions(role_norm)
|
||||
selection = {
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"title": selected.title,
|
||||
"labels": list(selected.labels),
|
||||
"head_sha": selected.head_sha,
|
||||
"priority": selected.priority,
|
||||
"expected_role_next": expected_role,
|
||||
"reason_selected": (
|
||||
f"highest-priority candidate for role '{role_norm}' "
|
||||
f"(expected_role={expected_role})"
|
||||
),
|
||||
}
|
||||
# Cross-role: lease/action matrix follows the required downstream role so
|
||||
# evidence names the worker that must act. Controller session still owns
|
||||
# the routing decision; mutation isolation is enforced by role gates on
|
||||
# mutation tools (controller profile lacks author/review/merge ops).
|
||||
lease_role = (
|
||||
expected_role if mode == ALLOCATION_MODE_CROSS_ROLE else role_norm
|
||||
)
|
||||
allowed, forbidden = role_actions(lease_role)
|
||||
# Controller must never receive mutation-class rights via cross-role apply.
|
||||
if role_norm == ROLE_CONTROLLER:
|
||||
ctrl_allowed, ctrl_forbidden = role_actions(ROLE_CONTROLLER)
|
||||
# Keep controller session capability evidence separate from lease_role.
|
||||
controller_allowed_actions = ctrl_allowed
|
||||
controller_forbidden_actions = ctrl_forbidden
|
||||
else:
|
||||
controller_allowed_actions = allowed
|
||||
controller_forbidden_actions = forbidden
|
||||
|
||||
selection = build_selection_dict(
|
||||
selected,
|
||||
active_role=role_norm,
|
||||
required_role=expected_role,
|
||||
profile_name=profile_name,
|
||||
allocation_mode=mode,
|
||||
)
|
||||
|
||||
if not apply:
|
||||
return {
|
||||
@@ -878,6 +1199,12 @@ def allocate_next_work(
|
||||
"outcome": OUTCOME_PREVIEW,
|
||||
"apply": False,
|
||||
"role": role_norm,
|
||||
"allocation_mode": mode,
|
||||
"routing_role": role_norm,
|
||||
"required_role": expected_role,
|
||||
"selected_action": selection["selected_action"],
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
@@ -889,10 +1216,22 @@ def allocate_next_work(
|
||||
"reasons": [
|
||||
"dry-run only (apply=false); no assignment/lease created — "
|
||||
"call again with apply=true to reserve via control-plane DB"
|
||||
+ (
|
||||
"; after apply, the required-role worker consumes via "
|
||||
"gitea_adopt_workflow_lease (#843)"
|
||||
if mode == ALLOCATION_MODE_CROSS_ROLE and expected_role != role_norm
|
||||
else ""
|
||||
)
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"allocation_evidence": {
|
||||
"mode": "preview",
|
||||
"allocation_mode": mode,
|
||||
"lease_created": False,
|
||||
"selection_policy": SELECTION_POLICY,
|
||||
},
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
@@ -902,18 +1241,24 @@ def allocate_next_work(
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"controller_allowed_actions": list(controller_allowed_actions),
|
||||
"controller_forbidden_actions": list(controller_forbidden_actions),
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
"allocator never assigns raw monitoring incidents; "
|
||||
"controller routes only under cross_role (#840)"
|
||||
),
|
||||
}
|
||||
|
||||
# Atomic reserve via #613 substrate.
|
||||
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
|
||||
try:
|
||||
cross_role_handoff = (
|
||||
mode == ALLOCATION_MODE_CROSS_ROLE and lease_role != role_norm
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"role": role_norm,
|
||||
"role": lease_role,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
@@ -922,7 +1267,8 @@ def allocate_next_work(
|
||||
"expected_head_sha": selected.head_sha,
|
||||
"allowed_actions": allowed,
|
||||
"forbidden_actions": forbidden,
|
||||
"phase": "allocated",
|
||||
# #843: mark cross-role allocations as awaiting independent consume
|
||||
"phase": "awaiting_handoff" if cross_role_handoff else "allocated",
|
||||
}
|
||||
if ttl is not None:
|
||||
kwargs["lease_ttl_seconds"] = int(ttl)
|
||||
@@ -992,11 +1338,72 @@ def allocate_next_work(
|
||||
}
|
||||
|
||||
# assigned
|
||||
return {
|
||||
lease_proof = {
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"expires_at": result.expires_at,
|
||||
"expected_head_sha": result.expected_head_sha,
|
||||
"allowed_actions": list(result.allowed_actions),
|
||||
"forbidden_actions": list(result.forbidden_actions),
|
||||
"lease_role": lease_role,
|
||||
"source": "control_plane_db.assign_and_lease",
|
||||
}
|
||||
consume_allocation = None
|
||||
if cross_role_handoff and result.lease_id:
|
||||
# Durable handoff marker so independent required-role workers can
|
||||
# consume without sharing the controller session (#843).
|
||||
handoff_prov = {
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"allocating_session_id": session_id,
|
||||
"allocating_role": role_norm,
|
||||
"required_role": expected_role,
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"allocation_mode": mode,
|
||||
"adopted_by_session_id": None,
|
||||
}
|
||||
try:
|
||||
db.attach_lease_provenance(result.lease_id, handoff_prov)
|
||||
except ControlPlaneError:
|
||||
# Still return assignment evidence; consume path may be unavailable
|
||||
handoff_prov["attach_failed"] = True
|
||||
consume_allocation = {
|
||||
"tool": "gitea_adopt_workflow_lease",
|
||||
"lease_id": result.lease_id,
|
||||
"assignment_id": result.assignment_id,
|
||||
"required_role": expected_role,
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"handoff_status": "pending",
|
||||
"controller_session_required": False,
|
||||
"instructions": (
|
||||
f"From an independent {expected_role} session "
|
||||
f"({selection['required_namespace']} / "
|
||||
f"{selection['required_profile']}), call "
|
||||
f"gitea_adopt_workflow_lease(lease_id={result.lease_id!r}) "
|
||||
"to consume this controller allocation. The allocating "
|
||||
"controller process does not need to remain alive. Wrong-role "
|
||||
"and second-adoption attempts fail closed."
|
||||
),
|
||||
}
|
||||
lease_proof["cross_role_handoff"] = True
|
||||
lease_proof["handoff_status"] = "pending"
|
||||
lease_proof["consume_tool"] = "gitea_adopt_workflow_lease"
|
||||
|
||||
out = {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"allocation_mode": mode,
|
||||
"routing_role": role_norm,
|
||||
"required_role": expected_role,
|
||||
"selected_action": selection["selected_action"],
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
@@ -1012,16 +1419,25 @@ def allocate_next_work(
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"lease_proof": {
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"expires_at": result.expires_at,
|
||||
"expected_head_sha": result.expected_head_sha,
|
||||
"allowed_actions": list(result.allowed_actions),
|
||||
"forbidden_actions": list(result.forbidden_actions),
|
||||
"source": "control_plane_db.assign_and_lease",
|
||||
"lease_proof": lease_proof,
|
||||
"allocation_evidence": {
|
||||
"mode": "assigned",
|
||||
"allocation_mode": mode,
|
||||
"lease_created": True,
|
||||
"lease_role": lease_role,
|
||||
"lease_proof": lease_proof,
|
||||
"selection_policy": SELECTION_POLICY,
|
||||
"cross_role_handoff": bool(cross_role_handoff),
|
||||
},
|
||||
"next_valid_command": _next_command(role_norm, selected),
|
||||
"next_valid_command": (
|
||||
(
|
||||
f"consume lease {result.lease_id} via gitea_adopt_workflow_lease "
|
||||
f"as {expected_role}, then "
|
||||
)
|
||||
+ _next_command(lease_role, selected)
|
||||
if cross_role_handoff
|
||||
else _next_command(lease_role, selected)
|
||||
),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
@@ -1031,11 +1447,19 @@ def allocate_next_work(
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"controller_allowed_actions": list(controller_allowed_actions),
|
||||
"controller_forbidden_actions": list(controller_forbidden_actions),
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
"allocator never assigns raw monitoring incidents; "
|
||||
"controller routes only under cross_role (#840); "
|
||||
"cross-role assignments are consumable by independent "
|
||||
"required-role workers via gitea_adopt_workflow_lease (#843)"
|
||||
),
|
||||
}
|
||||
if consume_allocation is not None:
|
||||
out["consume_allocation"] = consume_allocation
|
||||
return out
|
||||
|
||||
|
||||
def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
@@ -1087,6 +1511,7 @@ def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
priority=priority,
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
|
||||
@@ -87,6 +87,7 @@ MUTATION_TASKS = frozenset({
|
||||
"edit_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"publish_unpublished_branch",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"cleanup_stale_claims",
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
"""ARCH-01 Foundation Slice A — atomic platform installation + authority kernel (#822).
|
||||
|
||||
Parents: #820, #821. **First implementation leaf of the ARCH-01 program.**
|
||||
|
||||
This module implements the smallest executable ARCH-01 foundation:
|
||||
|
||||
* a connection-bound authenticated actor context (``cp_actor_*`` /
|
||||
``cp_operation_mode`` / ``cp_context_epoch`` SQLite scalar functions that SQL
|
||||
may *read* but can never *set* — ``[TRUSTED-SERVICE]`` authenticity);
|
||||
* an immutable authority-dominance lattice with an exact seeded tuple set
|
||||
(``[SCHEMA]``);
|
||||
* the principal-equivalence root (a class exists *before* its first principal;
|
||||
``principals.current_class_id`` is ``NOT NULL``; ``[SCHEMA]``);
|
||||
* a single-transaction platform installation that seeds the initial
|
||||
``platform.bootstrap`` grant and an immutable ``installed`` marker, validated
|
||||
by a fail-closed ``install_state`` ``BEFORE INSERT`` trigger (``[SCHEMA]``).
|
||||
|
||||
Everything else in the ARCH-01/02/04 program (evidence stores, repository
|
||||
bindings, workspaces, PostgreSQL parity, full grant succession, full principal
|
||||
merge) is out of scope here and tracked in its own issue — see #822 §5/§17.
|
||||
|
||||
**Readiness / production posture.** This subsystem is *disabled by default*.
|
||||
Nothing in the running MCP server imports or enables it. It becomes a security
|
||||
boundary only once its readiness checks (the ACs in #822) pass in the target
|
||||
environment. Instantiating :class:`PlatformKernel` creates an isolated SQLite
|
||||
database and never touches the operational control-plane store.
|
||||
|
||||
Enforcement classification (per #820 vocabulary):
|
||||
|
||||
* ``[TRUSTED-SERVICE]`` — actor-context authenticity: the scalar functions are
|
||||
registered by the trusted Python process; SQL cannot define or redefine them.
|
||||
* ``[SCHEMA]`` — fail-closed aborts, the dominance/immutability/NOT-NULL-class/
|
||||
last-active-grant invariants, enforced by CHECK/FK/trigger.
|
||||
* ``[RUNTIME-ADAPTER]`` — *none* in this slice.
|
||||
|
||||
SQLite-first. ``BEGIN IMMEDIATE`` serializes concurrent installs and concurrent
|
||||
grant/revoke on the singleton invariant row. PostgreSQL parity is a distinct
|
||||
issue (#827); this module does **not** claim it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterator, Optional
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Closed enumerations (#822 §4).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
ACTOR_KINDS = ("operator", "supervisor", "service", "installer")
|
||||
OPERATION_MODES = ("normal", "install", "merge", "internal_service")
|
||||
|
||||
# Exact seeded authority-dominance tuple set (#822 §4). This set is normative:
|
||||
# the install-state trigger rejects any missing, additional, or malformed tuple.
|
||||
DOMINANCE_TUPLES = (
|
||||
("platform.bootstrap", "platform.bootstrap"),
|
||||
("platform.bootstrap", "project.admin"),
|
||||
("platform.bootstrap", "supervisor.root.establish"),
|
||||
("supervisor.root", "supervisor.register"),
|
||||
("supervisor.root", "supervisor.verify"),
|
||||
("supervisor.root", "supervisor.recover"),
|
||||
)
|
||||
|
||||
# The distinguished operator-key issuer seeded during install.
|
||||
DISTINGUISHED_ISSUER_KIND = "operator-key"
|
||||
DISTINGUISHED_ISSUER_ID = "platform.bootstrap.operator-key"
|
||||
|
||||
# Structured result codes (#822 §10).
|
||||
INSTALLED = "INSTALLED"
|
||||
ALREADY_INSTALLED = "ALREADY_INSTALLED"
|
||||
INVALID_ACTOR_CONTEXT = "INVALID_ACTOR_CONTEXT"
|
||||
INVALID_BOOTSTRAP_STATE = "INVALID_BOOTSTRAP_STATE"
|
||||
DOMINANCE_SET_MISMATCH = "DOMINANCE_SET_MISMATCH"
|
||||
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
|
||||
CONCURRENT_INSTALLATION_LOST = "CONCURRENT_INSTALLATION_LOST"
|
||||
|
||||
# Required audit events (#822 §14).
|
||||
EVT_PLATFORM_INSTALLED = "platform_installed"
|
||||
EVT_GRANT_CREATED = "platform_grant_created"
|
||||
EVT_GRANT_REVOKED = "platform_grant_revoked"
|
||||
EVT_PRINCIPAL_REGISTERED = "principal_registered"
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
DB_PATH_ENV = "ARCH01_PLATFORM_DB"
|
||||
|
||||
|
||||
class PlatformKernelError(RuntimeError):
|
||||
"""Base class for structured, code-bearing kernel failures."""
|
||||
|
||||
def __init__(self, code: str, message: str = "") -> None:
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
class ActorContextError(PlatformKernelError):
|
||||
"""Raised when a mutation is attempted without a valid actor context."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schema (#822 §6). Tables + fail-closed triggers.
|
||||
#
|
||||
# Every *mutating* trigger opens with the actor protocol: read the context
|
||||
# epoch, read the actor fields, and abort unless the context is present,
|
||||
# non-null, mode/kind well-formed, and epoch-consistent with the active
|
||||
# transaction. The scalar functions ``cp_*`` are registered from Python only;
|
||||
# SQL has no statement that can set them, which is the trusted-service boundary.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_ACTOR_KINDS_SQL = ", ".join("'%s'" % k for k in ACTOR_KINDS)
|
||||
_OP_MODES_SQL = ", ".join("'%s'" % m for m in OPERATION_MODES)
|
||||
|
||||
# Actor-protocol predicate: TRUE when the context is INVALID and the trigger
|
||||
# must abort. ``cp_actor_context_valid()`` folds "present + non-expired +
|
||||
# live-epoch == bound-epoch" (the read/re-read epoch equality of #822 §4) into
|
||||
# one trusted-service answer; the remaining reads assert field well-formedness.
|
||||
_INVALID_ACTOR = (
|
||||
"cp_actor_context_valid() IS NOT 1 "
|
||||
"OR cp_context_epoch() IS NULL "
|
||||
"OR cp_actor_principal() IS NULL "
|
||||
"OR cp_actor_kind() NOT IN (%s) "
|
||||
"OR cp_operation_mode() NOT IN (%s)" % (_ACTOR_KINDS_SQL, _OP_MODES_SQL)
|
||||
)
|
||||
|
||||
_ACTOR_GUARD = (
|
||||
"SELECT CASE WHEN (%s) "
|
||||
"THEN RAISE(ABORT, 'INVALID_ACTOR_CONTEXT') END;" % _INVALID_ACTOR
|
||||
)
|
||||
|
||||
# require_installed: abort a privileged mutation when there is no install
|
||||
# marker and we are not currently installing (#822 §4).
|
||||
_REQUIRE_INSTALLED = (
|
||||
"SELECT CASE WHEN ((SELECT COUNT(*) FROM install_state) = 0 "
|
||||
"AND cp_operation_mode() <> 'install') "
|
||||
"THEN RAISE(ABORT, 'NOT_INSTALLED') END;"
|
||||
)
|
||||
|
||||
_SCHEMA_SQL = f"""
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arch01_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Equivalence classes are created BEFORE their first principal (#822 §4).
|
||||
CREATE TABLE IF NOT EXISTS principal_equivalence_classes (
|
||||
class_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authoritative_issuers (
|
||||
issuer_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issuer_kind TEXT NOT NULL,
|
||||
issuer_ref TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (issuer_kind, issuer_ref)
|
||||
);
|
||||
|
||||
-- current_class_id is NOT NULL: a principal cannot exist without a class
|
||||
-- (#822 AC6). issuer_id is nullable ONLY for the installer during install
|
||||
-- (#822 AC7), enforced by trg_principals_null_issuer below.
|
||||
CREATE TABLE IF NOT EXISTS principals (
|
||||
principal_id TEXT PRIMARY KEY,
|
||||
actor_kind TEXT NOT NULL CHECK (actor_kind IN ({_ACTOR_KINDS_SQL})),
|
||||
current_class_id INTEGER NOT NULL REFERENCES principal_equivalence_classes(class_id),
|
||||
issuer_id INTEGER REFERENCES authoritative_issuers(issuer_id),
|
||||
registered_by TEXT REFERENCES principals(principal_id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authority_dominance (
|
||||
dominant TEXT NOT NULL,
|
||||
subordinate TEXT NOT NULL,
|
||||
PRIMARY KEY (dominant, subordinate)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_bootstrap_seed (
|
||||
seed_id INTEGER PRIMARY KEY CHECK (seed_id = 1),
|
||||
installer_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_bootstrap_grants (
|
||||
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
grantee_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
|
||||
granted_by TEXT REFERENCES principals(principal_id),
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- Singleton row; active_count floored at 1 by CHECK so the last active grant
|
||||
-- can never be revoked (#822 AC11).
|
||||
CREATE TABLE IF NOT EXISTS platform_active_invariant (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
active_count INTEGER NOT NULL CHECK (active_count >= 1)
|
||||
);
|
||||
|
||||
-- The immutable install marker; inserted LAST in the install transaction.
|
||||
CREATE TABLE IF NOT EXISTS install_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
marker TEXT NOT NULL CHECK (marker = 'installed'),
|
||||
installed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Append-only (#822 AC14).
|
||||
CREATE TABLE IF NOT EXISTS audit_records (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event TEXT NOT NULL,
|
||||
principal_id TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------- --
|
||||
-- Actor protocol on every mutating trigger (#822 §4, [SCHEMA] fail-closed).
|
||||
-- ------------------------------------------------------------------------- --
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_classes_actor
|
||||
BEFORE INSERT ON principal_equivalence_classes
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_issuers_actor
|
||||
BEFORE INSERT ON authoritative_issuers
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_principals_actor
|
||||
BEFORE INSERT ON principals
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_dominance_actor
|
||||
BEFORE INSERT ON authority_dominance
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_seed_actor
|
||||
BEFORE INSERT ON platform_bootstrap_seed
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_insert
|
||||
BEFORE INSERT ON platform_bootstrap_grants
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
{_REQUIRE_INSTALLED}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_update
|
||||
BEFORE UPDATE ON platform_bootstrap_grants
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_insert
|
||||
BEFORE INSERT ON platform_active_invariant
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_update
|
||||
BEFORE UPDATE ON platform_active_invariant
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_audit_actor
|
||||
BEFORE INSERT ON audit_records
|
||||
BEGIN
|
||||
{_ACTOR_GUARD}
|
||||
END;
|
||||
|
||||
-- ------------------------------------------------------------------------- --
|
||||
-- NOT-NULL-issuer exception for the installer only (#822 AC7).
|
||||
-- A NULL issuer_id is accepted solely for an installer principal during
|
||||
-- install mode, before the marker exists; any other NULL-issuer principal is
|
||||
-- rejected. install-time issuer linkage (installer -> distinguished issuer)
|
||||
-- is applied by a later UPDATE, permitted while no marker exists.
|
||||
-- ------------------------------------------------------------------------- --
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_principals_null_issuer
|
||||
BEFORE INSERT ON principals
|
||||
WHEN NEW.issuer_id IS NULL
|
||||
BEGIN
|
||||
SELECT CASE WHEN NOT (
|
||||
NEW.actor_kind = 'installer'
|
||||
AND cp_operation_mode() = 'install'
|
||||
AND (SELECT COUNT(*) FROM install_state) = 0
|
||||
AND (SELECT COUNT(*) FROM principals WHERE issuer_id IS NULL) = 0
|
||||
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
|
||||
END;
|
||||
|
||||
-- ------------------------------------------------------------------------- --
|
||||
-- Post-install immutability of the authority root (#822 §4, AC9).
|
||||
-- Registration fields freeze only AFTER the marker exists, so the install
|
||||
-- transaction's own installer issuer-linkage UPDATE is permitted.
|
||||
-- ------------------------------------------------------------------------- --
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_update
|
||||
BEFORE UPDATE ON principals
|
||||
WHEN (SELECT COUNT(*) FROM install_state) > 0
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_delete
|
||||
BEFORE DELETE ON principals
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
|
||||
END;
|
||||
|
||||
-- Distinguished issuer identity is immutable once written.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_update
|
||||
BEFORE UPDATE ON authoritative_issuers
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_delete
|
||||
BEFORE DELETE ON authoritative_issuers
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
|
||||
END;
|
||||
|
||||
-- The dominance lattice is immutable once seeded.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_update
|
||||
BEFORE UPDATE ON authority_dominance
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_delete
|
||||
BEFORE DELETE ON authority_dominance
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
|
||||
END;
|
||||
|
||||
-- The bootstrap seed is immutable once written.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_update
|
||||
BEFORE UPDATE ON platform_bootstrap_seed
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_delete
|
||||
BEFORE DELETE ON platform_bootstrap_seed
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
|
||||
END;
|
||||
|
||||
-- The install marker is immutable once written.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_update
|
||||
BEFORE UPDATE ON install_state
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_delete
|
||||
BEFORE DELETE ON install_state
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
|
||||
END;
|
||||
|
||||
-- Grants: identity is immutable; the ONLY permitted mutation is a single
|
||||
-- active 1 -> 0 revocation (#822 §4 initial-grant identity immutability +
|
||||
-- grant/revoke). Reactivation and identity edits are rejected.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_grants_identity_frozen
|
||||
BEFORE UPDATE ON platform_bootstrap_grants
|
||||
WHEN NOT (
|
||||
NEW.grant_id = OLD.grant_id
|
||||
AND NEW.grantee_principal_id = OLD.grantee_principal_id
|
||||
AND NEW.granted_by IS OLD.granted_by
|
||||
AND NEW.created_at = OLD.created_at
|
||||
AND OLD.active = 1
|
||||
AND NEW.active = 0
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_grants_no_delete
|
||||
BEFORE DELETE ON platform_bootstrap_grants
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
|
||||
END;
|
||||
|
||||
-- audit_records is append-only.
|
||||
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_update
|
||||
BEFORE UPDATE ON audit_records
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_delete
|
||||
BEFORE DELETE ON audit_records
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
|
||||
END;
|
||||
|
||||
-- ------------------------------------------------------------------------- --
|
||||
-- install_state BEFORE INSERT: validate the whole bootstrap atomically
|
||||
-- (#822 §4, AC4). Each dominance tuple is checked individually; a missing,
|
||||
-- additional, or malformed tuple -> DOMINANCE_SET_MISMATCH. The seed<->installer
|
||||
-- link, the single active NULL-grantor installer grant, the installer's
|
||||
-- non-NULL issuer, the active invariant, and "no extra principal created under
|
||||
-- the NULL-issuer exception" -> INVALID_BOOTSTRAP_STATE.
|
||||
-- ------------------------------------------------------------------------- --
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_install_state_validate
|
||||
BEFORE INSERT ON install_state
|
||||
BEGIN
|
||||
SELECT CASE WHEN NOT (
|
||||
(SELECT COUNT(*) FROM authority_dominance) = {len(DOMINANCE_TUPLES)}
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='platform.bootstrap')
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='project.admin')
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='supervisor.root.establish')
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.register')
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.verify')
|
||||
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.recover')
|
||||
) THEN RAISE(ABORT, 'DOMINANCE_SET_MISMATCH') END;
|
||||
|
||||
SELECT CASE WHEN NOT (
|
||||
(SELECT COUNT(*) FROM platform_bootstrap_seed) = 1
|
||||
AND (SELECT COUNT(*) FROM principals) = 1
|
||||
AND (SELECT actor_kind FROM principals
|
||||
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||
) = 'installer'
|
||||
AND (SELECT issuer_id FROM principals
|
||||
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||
) IS NOT NULL
|
||||
AND (SELECT COUNT(*) FROM platform_bootstrap_grants
|
||||
WHERE granted_by IS NULL AND active = 1
|
||||
AND grantee_principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||
) = 1
|
||||
AND (SELECT COUNT(*) FROM platform_bootstrap_grants) = 1
|
||||
AND (SELECT active_count FROM platform_active_invariant WHERE id = 1) = 1
|
||||
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
def default_db_path() -> str:
|
||||
return os.environ.get(
|
||||
DB_PATH_ENV,
|
||||
os.path.expanduser("~/.cache/gitea-tools/arch01/platform.sqlite3"),
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationResult:
|
||||
"""Structured result of a kernel operation (#822 §10)."""
|
||||
|
||||
code: str
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.code in (INSTALLED, ALREADY_INSTALLED)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ActorContext:
|
||||
principal: str
|
||||
kind: str
|
||||
mode: str
|
||||
session: Optional[str]
|
||||
bound_epoch: int
|
||||
live_epoch: int
|
||||
expired: bool = False
|
||||
|
||||
|
||||
class PlatformKernel:
|
||||
"""ARCH-01 authority kernel over a single SQLite connection.
|
||||
|
||||
The connection carries the trusted-service actor context: the ``cp_*``
|
||||
scalar functions read the context this object holds. Only Python code here
|
||||
can bind or clear it, so no SQL statement can assert an actor identity — the
|
||||
trusted-service authenticity boundary of #822 §4.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, *, busy_timeout_ms: int = 5000) -> None:
|
||||
self.db_path = db_path or default_db_path()
|
||||
if self.db_path != ":memory:":
|
||||
parent = os.path.dirname(self.db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
self._ctx: Optional[_ActorContext] = None
|
||||
self._epoch_seq = 0
|
||||
self._lock = threading.Lock()
|
||||
# check_same_thread=False is safe: every mutation path is serialized
|
||||
# by self._lock, so the connection is never used concurrently even when
|
||||
# callers drive the kernel from different threads (concurrency tests).
|
||||
self._conn = sqlite3.connect(
|
||||
self.db_path, isolation_level=None, check_same_thread=False
|
||||
)
|
||||
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
self._conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
|
||||
self._register_actor_functions()
|
||||
self._migrate()
|
||||
|
||||
# -- trusted-service actor functions ---------------------------------- #
|
||||
|
||||
def _register_actor_functions(self) -> None:
|
||||
c = self._conn
|
||||
c.create_function("cp_actor_principal", 0, lambda: self._ctx.principal if self._ctx else None)
|
||||
c.create_function("cp_actor_kind", 0, lambda: self._ctx.kind if self._ctx else None)
|
||||
c.create_function("cp_operation_mode", 0, lambda: self._ctx.mode if self._ctx else None)
|
||||
c.create_function("cp_service_session", 0, lambda: self._ctx.session if self._ctx else None)
|
||||
c.create_function("cp_context_epoch", 0, self._fn_context_epoch)
|
||||
# Trusted-service helper: folds present + non-expired + epoch-consistent
|
||||
# into the read/re-read epoch equality of #822 §4.
|
||||
c.create_function("cp_actor_context_valid", 0, self._fn_context_valid)
|
||||
|
||||
def _fn_context_epoch(self) -> Optional[int]:
|
||||
if self._ctx is None or self._ctx.expired:
|
||||
return None
|
||||
return self._ctx.live_epoch
|
||||
|
||||
def _fn_context_valid(self) -> int:
|
||||
ctx = self._ctx
|
||||
if ctx is None or ctx.expired:
|
||||
return 0
|
||||
# read/re-read epoch equality: a context whose live epoch has drifted
|
||||
# from the epoch it was bound to (a stale/replaced connection context)
|
||||
# is not bound to the active transaction and fails closed.
|
||||
if ctx.live_epoch != ctx.bound_epoch:
|
||||
return 0
|
||||
if ctx.principal is None:
|
||||
return 0
|
||||
if ctx.kind not in ACTOR_KINDS or ctx.mode not in OPERATION_MODES:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
# -- context lifecycle ------------------------------------------------ #
|
||||
|
||||
@contextmanager
|
||||
def actor_context(
|
||||
self, principal: str, kind: str, mode: str, session: Optional[str] = None
|
||||
) -> Iterator[None]:
|
||||
"""Bind a trusted actor context for the duration of the block."""
|
||||
prev = self._ctx
|
||||
self._epoch_seq += 1
|
||||
epoch = self._epoch_seq
|
||||
self._ctx = _ActorContext(
|
||||
principal=principal, kind=kind, mode=mode, session=session,
|
||||
bound_epoch=epoch, live_epoch=epoch,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._ctx = prev
|
||||
|
||||
def _clear_context(self) -> None:
|
||||
self._ctx = None
|
||||
|
||||
# -- migration -------------------------------------------------------- #
|
||||
|
||||
def _migrate(self) -> None:
|
||||
self._conn.executescript(_SCHEMA_SQL)
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES ('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES "
|
||||
"('architecture', 'ARCH-01 Slice A: atomic install + authority kernel (#822); "
|
||||
"disabled by default until readiness checks pass')"
|
||||
)
|
||||
|
||||
# -- introspection ---------------------------------------------------- #
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
row = self._conn.execute("SELECT COUNT(*) FROM install_state").fetchone()
|
||||
return bool(row[0])
|
||||
|
||||
def active_grant_count(self) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT active_count FROM platform_active_invariant WHERE id = 1"
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def audit_events(self) -> list[str]:
|
||||
return [
|
||||
r[0]
|
||||
for r in self._conn.execute(
|
||||
"SELECT event FROM audit_records ORDER BY audit_id"
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
# -- operations ------------------------------------------------------- #
|
||||
|
||||
def install_platform(
|
||||
self,
|
||||
installer_principal_id: str = "platform.installer",
|
||||
*,
|
||||
session: Optional[str] = None,
|
||||
) -> OperationResult:
|
||||
"""Single atomic install transaction (#822 §4/§7).
|
||||
|
||||
``BEGIN IMMEDIATE`` serializes concurrent installs; the loser rechecks
|
||||
the marker and returns ``ALREADY_INSTALLED``, or — if it never acquires
|
||||
the write lock — ``CONCURRENT_INSTALLATION_LOST``. On any stage failure
|
||||
the whole transaction rolls back leaving no partial rows (AC3/AC5).
|
||||
"""
|
||||
now = _utc_now_iso()
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
return OperationResult(CONCURRENT_INSTALLATION_LOST, str(exc))
|
||||
raise
|
||||
try:
|
||||
if self.is_installed():
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(ALREADY_INSTALLED, "install marker already present")
|
||||
|
||||
with self.actor_context(installer_principal_id, "installer", "install", session):
|
||||
c = self._conn
|
||||
# class -> installer principal (temporary NULL issuer)
|
||||
cur = c.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
|
||||
(now,),
|
||||
)
|
||||
class_id = cur.lastrowid
|
||||
c.execute(
|
||||
"INSERT INTO principals"
|
||||
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||
(installer_principal_id, class_id, installer_principal_id, now),
|
||||
)
|
||||
# distinguished operator-key issuer
|
||||
cur = c.execute(
|
||||
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(DISTINGUISHED_ISSUER_KIND, DISTINGUISHED_ISSUER_ID, now),
|
||||
)
|
||||
issuer_id = cur.lastrowid
|
||||
# link installer -> issuer (permitted pre-marker)
|
||||
c.execute(
|
||||
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||
(issuer_id, installer_principal_id),
|
||||
)
|
||||
# dominance tuples
|
||||
c.executemany(
|
||||
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||
DOMINANCE_TUPLES,
|
||||
)
|
||||
# seed
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) "
|
||||
"VALUES (1, ?, ?)",
|
||||
(installer_principal_id, now),
|
||||
)
|
||||
# initial grant (granted_by NULL, active)
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_grants"
|
||||
"(grantee_principal_id, granted_by, active, created_at) "
|
||||
"VALUES (?, NULL, 1, ?)",
|
||||
(installer_principal_id, now),
|
||||
)
|
||||
# active invariant
|
||||
c.execute(
|
||||
"INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)"
|
||||
)
|
||||
# audit rows for the security-sensitive operation
|
||||
c.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_PRINCIPAL_REGISTERED, installer_principal_id, "installer", now),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_GRANT_CREATED, installer_principal_id, "initial platform.bootstrap grant", now),
|
||||
)
|
||||
# install marker LAST -> fires the whole-bootstrap validator
|
||||
c.execute(
|
||||
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
|
||||
(now,),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_PLATFORM_INSTALLED, installer_principal_id, "platform installed", now),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
return OperationResult(INSTALLED, "platform installed")
|
||||
except sqlite3.Error as exc:
|
||||
self._safe_rollback()
|
||||
return OperationResult(self._classify(exc), str(exc))
|
||||
|
||||
def register_principal(
|
||||
self,
|
||||
principal_id: str,
|
||||
actor_kind: str,
|
||||
issuer_ref: str,
|
||||
*,
|
||||
actor_principal: str,
|
||||
actor_kind_ctx: str = "operator",
|
||||
session: Optional[str] = None,
|
||||
) -> OperationResult:
|
||||
"""Atomically create an equivalence class and its first principal.
|
||||
|
||||
The class is inserted *before* the principal, and ``current_class_id``
|
||||
is ``NOT NULL`` (#822 AC6): a principal can never exist classless.
|
||||
The principal references an existing issuer (non-NULL); the temporary
|
||||
NULL-issuer exception is reserved for the installer during install
|
||||
(AC7).
|
||||
"""
|
||||
if actor_kind not in ACTOR_KINDS:
|
||||
return OperationResult(INVALID_BOOTSTRAP_STATE, f"bad actor_kind {actor_kind!r}")
|
||||
now = _utc_now_iso()
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||
try:
|
||||
if not self.is_installed():
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||
row = self._conn.execute(
|
||||
"SELECT issuer_id FROM authoritative_issuers WHERE issuer_ref = ?",
|
||||
(issuer_ref,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(INVALID_BOOTSTRAP_STATE, f"unknown issuer {issuer_ref!r}")
|
||||
issuer_id = row[0]
|
||||
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
|
||||
(now,),
|
||||
)
|
||||
class_id = cur.lastrowid
|
||||
self._conn.execute(
|
||||
"INSERT INTO principals"
|
||||
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(principal_id, actor_kind, class_id, issuer_id, actor_principal, now),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_PRINCIPAL_REGISTERED, principal_id, actor_kind, now),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
return OperationResult(INSTALLED, f"registered {principal_id}")
|
||||
except sqlite3.Error as exc:
|
||||
self._safe_rollback()
|
||||
return OperationResult(self._classify(exc), str(exc))
|
||||
|
||||
def grant_platform_bootstrap(
|
||||
self,
|
||||
grantee_principal_id: str,
|
||||
granted_by: str,
|
||||
*,
|
||||
actor_kind_ctx: str = "operator",
|
||||
session: Optional[str] = None,
|
||||
) -> OperationResult:
|
||||
"""Create an additional active platform.bootstrap grant.
|
||||
|
||||
Serialized on the singleton invariant row via ``BEGIN IMMEDIATE``.
|
||||
"""
|
||||
now = _utc_now_iso()
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||
try:
|
||||
if not self.is_installed():
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||
with self.actor_context(granted_by, actor_kind_ctx, "normal", session):
|
||||
self._conn.execute(
|
||||
"INSERT INTO platform_bootstrap_grants"
|
||||
"(grantee_principal_id, granted_by, active, created_at) "
|
||||
"VALUES (?, ?, 1, ?)",
|
||||
(grantee_principal_id, granted_by, now),
|
||||
)
|
||||
self._conn.execute(
|
||||
"UPDATE platform_active_invariant SET active_count = active_count + 1 WHERE id = 1"
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_GRANT_CREATED, grantee_principal_id, f"granted_by={granted_by}", now),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
return OperationResult(INSTALLED, f"granted to {grantee_principal_id}")
|
||||
except sqlite3.Error as exc:
|
||||
self._safe_rollback()
|
||||
return OperationResult(self._classify(exc), str(exc))
|
||||
|
||||
def revoke_platform_bootstrap(
|
||||
self,
|
||||
grant_id: int,
|
||||
*,
|
||||
actor_principal: str,
|
||||
actor_kind_ctx: str = "operator",
|
||||
session: Optional[str] = None,
|
||||
) -> OperationResult:
|
||||
"""Revoke an active grant, floored so the last one can never drop.
|
||||
|
||||
The ``active_count >= 1`` CHECK plus ``BEGIN IMMEDIATE`` serialization
|
||||
make two concurrent revocations unable to remove the final active grant
|
||||
(#822 AC11): the decrement that would reach zero fails and rolls back.
|
||||
"""
|
||||
now = _utc_now_iso()
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||
try:
|
||||
if not self.is_installed():
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||
row = self._conn.execute(
|
||||
"SELECT active, grantee_principal_id FROM platform_bootstrap_grants WHERE grant_id = ?",
|
||||
(grant_id,),
|
||||
).fetchone()
|
||||
if row is None or row[0] != 1:
|
||||
self._conn.execute("ROLLBACK")
|
||||
return OperationResult(AUTHORIZATION_DENIED, "grant absent or already inactive")
|
||||
grantee = row[1]
|
||||
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
|
||||
# Decrement first: the CHECK floor rejects dropping below 1,
|
||||
# aborting the whole revoke before the grant flips inactive.
|
||||
self._conn.execute(
|
||||
"UPDATE platform_active_invariant SET active_count = active_count - 1 WHERE id = 1"
|
||||
)
|
||||
self._conn.execute(
|
||||
"UPDATE platform_bootstrap_grants SET active = 0, revoked_at = ? WHERE grant_id = ?",
|
||||
(now, grant_id),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(EVT_GRANT_REVOKED, grantee, f"grant_id={grant_id}", now),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
return OperationResult(INSTALLED, f"revoked grant {grant_id}")
|
||||
except sqlite3.Error as exc:
|
||||
self._safe_rollback()
|
||||
return OperationResult(self._classify(exc), str(exc))
|
||||
|
||||
# -- helpers ---------------------------------------------------------- #
|
||||
|
||||
def _safe_rollback(self) -> None:
|
||||
try:
|
||||
self._conn.execute("ROLLBACK")
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _classify(exc: sqlite3.Error) -> str:
|
||||
msg = str(exc)
|
||||
if "INVALID_ACTOR_CONTEXT" in msg:
|
||||
return INVALID_ACTOR_CONTEXT
|
||||
if "DOMINANCE_SET_MISMATCH" in msg:
|
||||
return DOMINANCE_SET_MISMATCH
|
||||
if "active_count" in msg or "CHECK constraint failed: platform_active_invariant" in msg:
|
||||
# last-active-grant floor tripped
|
||||
return AUTHORIZATION_DENIED
|
||||
if any(tag in msg for tag in (
|
||||
"INVALID_BOOTSTRAP_STATE", "IMMUTABLE_", "NOT_INSTALLED",
|
||||
)):
|
||||
return INVALID_BOOTSTRAP_STATE
|
||||
return INVALID_BOOTSTRAP_STATE
|
||||
File diff suppressed because it is too large
Load Diff
+80
-37
@@ -40,22 +40,88 @@ def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def get_canonical_branches_root(project_root: str | None = None) -> str:
|
||||
"""Return the absolute path of the canonical branches directory for *project_root*."""
|
||||
root = os.path.realpath(project_root) if project_root else os.path.realpath(os.getcwd())
|
||||
canonical_repo_root = resolve_canonical_repo_root(root, root)
|
||||
return os.path.realpath(os.path.join(canonical_repo_root, "branches"))
|
||||
|
||||
|
||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
"""True when *path* resolves inside a canonical ``branches/`` directory."""
|
||||
if not path or not str(path).strip():
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(os.path.realpath(project_root))
|
||||
real = _normalize_path(os.path.realpath(path))
|
||||
if real.startswith(f"{root}/"):
|
||||
rel = real[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
try:
|
||||
real_path = os.path.realpath(os.path.abspath(str(path).strip()))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
branches_root = get_canonical_branches_root(project_root or real_path)
|
||||
try:
|
||||
common = os.path.commonpath([branches_root, real_path])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if common != branches_root:
|
||||
return False
|
||||
|
||||
rel = os.path.relpath(real_path, branches_root)
|
||||
return rel != "." and not rel.startswith("..")
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
p = (workspace_path or "").strip()
|
||||
if p:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", p, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(p, res.stdout)
|
||||
if common.endswith(f"{os.sep}.git") or os.path.basename(common) == ".git":
|
||||
candidate_root = os.path.dirname(common)
|
||||
real_p = os.path.realpath(p)
|
||||
try:
|
||||
if os.path.commonpath([candidate_root, real_p]) == candidate_root:
|
||||
return candidate_root
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback when git metadata is unavailable. Never string-split on
|
||||
# "/branches/" (review #531 F2 / #551): recover the repo root only via
|
||||
# resolved-path commonpath ancestry. Do **not** require on-disk isdir —
|
||||
# MCP may launch with project_root = branches/<wt> before that path
|
||||
# exists, and #274 path-shaped worktree-as-project-root must still resolve.
|
||||
fallback = os.path.realpath(fallback_project_root or workspace_path or ".")
|
||||
cur = fallback
|
||||
for _ in range(64):
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
branches_dir = os.path.realpath(os.path.join(parent, "branches"))
|
||||
try:
|
||||
# Path-shaped: fallback is under parent/branches/ (commonpath).
|
||||
if os.path.commonpath([branches_dir, fallback]) == branches_dir:
|
||||
return parent
|
||||
except ValueError:
|
||||
pass
|
||||
# Fallback path itself is the branches directory.
|
||||
if os.path.basename(os.path.realpath(cur)) == "branches":
|
||||
try:
|
||||
if os.path.commonpath([os.path.realpath(cur), fallback]) == os.path.realpath(
|
||||
cur
|
||||
):
|
||||
return parent
|
||||
except ValueError:
|
||||
pass
|
||||
cur = parent
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_mutation_workspace(
|
||||
@@ -87,29 +153,6 @@ def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
|
||||
+97
-1
@@ -163,7 +163,19 @@ _TERMINAL_OWNERSHIP_STATUSES = frozenset(
|
||||
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
|
||||
)
|
||||
_EXPIRED_STATUSES = frozenset({"expired"})
|
||||
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
|
||||
_STALE_STATUSES = frozenset(
|
||||
{
|
||||
"stale",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
# #790 Slice A heartbeat-lifecycle bands. Listed here so they are
|
||||
# *classified* rather than falling through to the unknown-status branch;
|
||||
# they still block unless the ownership record proves
|
||||
# ``reclaim_allowed is True``, so the O2 fail-closed rule is unchanged.
|
||||
"stale_missed_heartbeat",
|
||||
"stale_absolute_cap",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _norm_str(value: Any) -> str:
|
||||
@@ -525,6 +537,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,
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).
|
||||
|
||||
Entry point B of #812 is the state where an author's work has already advanced
|
||||
to a local commit: the worktree is registered, clean, on the issue branch, and
|
||||
carries the only copy of the implementation, but the branch has never been
|
||||
published. That state deadlocks, because two individually correct predicates
|
||||
close a cycle:
|
||||
|
||||
* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
|
||||
expired lease without an observable remote head — an unpublished branch has
|
||||
none.
|
||||
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
|
||||
its workspace from the author issue lock under #618, so nothing can create
|
||||
that remote head without first holding the lock.
|
||||
|
||||
This module supplies the missing operation: it publishes an *already committed*
|
||||
local head to the remote branch, so exact-owner renewal has the evidence it
|
||||
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
|
||||
lock. Publication is the whole of its authority.
|
||||
|
||||
Why this is not a lock bypass
|
||||
-----------------------------
|
||||
The operation can only publish a branch whose **durable issue-lock record
|
||||
already names the caller as claimant**. Ownership is read from the lock file on
|
||||
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
|
||||
cannot manufacture a claim it does not already hold. Nothing here weakens the
|
||||
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
|
||||
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
|
||||
they do today. The only thing this adds is the ability to make an existing,
|
||||
owned, committed, clean branch observable on the remote.
|
||||
|
||||
Separation of records (#812 AC23)
|
||||
---------------------------------
|
||||
The durable **issue-lock file** and the control-plane **workflow lease** are
|
||||
distinct records. This module reads the former as ownership evidence and writes
|
||||
neither. Publishing changes remote git state only; no lock is renewed,
|
||||
abandoned, reclaimed, or generation-bumped here.
|
||||
|
||||
Process evidence (#812 AC24)
|
||||
----------------------------
|
||||
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
|
||||
the recorded pid routinely belongs to the long-running MCP daemon rather than to
|
||||
an active author client, and the existing reclaim predicate
|
||||
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
|
||||
Publication does not require the recording process to be dead, so this module
|
||||
never asserts, infers, or depends on a process being dead. Ownership is proven
|
||||
by identity and profile match against the recorded claimant instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
from stable_branch_push_guard import is_stable_ref, redact_command
|
||||
|
||||
# Assessment outcomes.
|
||||
PUBLISH_SANCTIONED = "publish_sanctioned"
|
||||
ALREADY_PUBLISHED = "already_published"
|
||||
REFUSED = "refused"
|
||||
|
||||
#: Implementation branches must stay traceable to their issue (#713 lineage).
|
||||
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")
|
||||
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _realpath(value: str | None) -> str | None:
|
||||
path = _text(value)
|
||||
return os.path.realpath(path) if path else None
|
||||
|
||||
|
||||
def parse_untracked_files(porcelain_status: str) -> list[str]:
|
||||
"""Return untracked paths from ``git status --porcelain`` output.
|
||||
|
||||
``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
|
||||
entries. Publication needs both halves: an untracked file in the worktree is
|
||||
unpublished content that the commit does not carry, so publishing would
|
||||
silently leave it behind.
|
||||
"""
|
||||
untracked: list[str] = []
|
||||
for line in (porcelain_status or "").splitlines():
|
||||
if not line.startswith("??"):
|
||||
continue
|
||||
path = line[2:].strip()
|
||||
if path:
|
||||
untracked.append(path)
|
||||
return untracked
|
||||
|
||||
|
||||
def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
|
||||
"""SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
|
||||
root = _text(worktree_path)
|
||||
hashes: dict[str, str | None] = {}
|
||||
for rel in paths or ():
|
||||
rel_text = _text(rel)
|
||||
if not rel_text:
|
||||
continue
|
||||
full = os.path.join(root, rel_text)
|
||||
try:
|
||||
with open(full, "rb") as handle:
|
||||
digest = hashlib.sha256()
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
hashes[rel_text] = digest.hexdigest()
|
||||
except OSError:
|
||||
hashes[rel_text] = None
|
||||
return hashes
|
||||
|
||||
|
||||
def read_remote_branch_head(
|
||||
worktree_path: str, remote_name: str, branch_name: str
|
||||
) -> dict:
|
||||
"""Observe the remote head for *branch_name*, read-only.
|
||||
|
||||
``probe_ok`` False means git could not answer at all. That is kept distinct
|
||||
from "the branch does not exist": an unobservable remote must fail closed
|
||||
rather than be mistaken for an absent branch, because the two lead to
|
||||
opposite dispositions.
|
||||
"""
|
||||
path = _text(worktree_path)
|
||||
remote = _text(remote_name)
|
||||
branch = _text(branch_name)
|
||||
result: dict = {
|
||||
"probe_ok": False,
|
||||
"remote_branch_exists": False,
|
||||
"remote_head_sha": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not (path and remote and branch):
|
||||
result["reasons"].append(
|
||||
"remote head probe requires a worktree path, remote name, and branch"
|
||||
)
|
||||
return result
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc: # git unavailable — fail closed, never assume absent
|
||||
result["reasons"].append(f"remote head probe could not run: {exc}")
|
||||
return result
|
||||
if res.returncode != 0:
|
||||
result["reasons"].append(
|
||||
f"remote head probe failed for '{branch}' on remote '{remote}'"
|
||||
)
|
||||
return result
|
||||
|
||||
result["probe_ok"] = True
|
||||
for line in (res.stdout or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
|
||||
result["remote_branch_exists"] = True
|
||||
result["remote_head_sha"] = parts[0].strip()
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def read_is_ancestor(
|
||||
worktree_path: str, ancestor_sha: str, descendant_sha: str
|
||||
) -> dict:
|
||||
"""Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
|
||||
path = _text(worktree_path)
|
||||
ancestor = _text(ancestor_sha)
|
||||
descendant = _text(descendant_sha)
|
||||
result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
|
||||
if not (path and ancestor and descendant):
|
||||
result["reasons"].append(
|
||||
"ancestry probe requires a worktree path and both commit SHAs"
|
||||
)
|
||||
return result
|
||||
try:
|
||||
present = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", "--quiet",
|
||||
f"{ancestor}^{{commit}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if present.returncode != 0:
|
||||
result["reasons"].append(
|
||||
f"remote head {ancestor} is not present locally, so it cannot be "
|
||||
"proven an ancestor of the commit being published"
|
||||
)
|
||||
return result
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||
return result
|
||||
result["probe_ok"] = res.returncode in (0, 1)
|
||||
result["is_ancestor"] = res.returncode == 0
|
||||
return result
|
||||
|
||||
|
||||
def assess_unpublished_commit_publication(
|
||||
existing_lock,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
expected_head: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
worktree_state,
|
||||
worktree_registered: bool | None = None,
|
||||
remote_probe=None,
|
||||
ancestry=None,
|
||||
competing_open_prs=(),
|
||||
expected_file_hashes=None,
|
||||
observed_file_hashes=None,
|
||||
) -> dict:
|
||||
"""Decide whether an unpublished local commit may be published (#812 AC20).
|
||||
|
||||
Pure predicate. Every input is either a caller-declared expectation that
|
||||
must be *matched* against observation, or a server-side observation. No
|
||||
caller-supplied boolean is accepted as proof of ownership, liveness, or
|
||||
eligibility: ``existing_lock`` comes from the durable lock file and the
|
||||
git/PR state is observed by the server.
|
||||
|
||||
The single mutating disposition it can return is "publish this exact commit
|
||||
to this exact branch". It never sanctions renewal, reclamation, force
|
||||
updates, history rewriting, or publication of uncommitted content.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
branch = _text(branch_name)
|
||||
head = _text(expected_head).lower()
|
||||
workspace = _realpath(worktree_path)
|
||||
state = worktree_state if isinstance(worktree_state, dict) else {}
|
||||
lock = existing_lock if isinstance(existing_lock, dict) else None
|
||||
|
||||
evidence: dict = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch or None,
|
||||
"worktree_path": workspace,
|
||||
"expected_head": head or None,
|
||||
"remote": _text(remote) or None,
|
||||
"org": _text(org) or None,
|
||||
"repo": _text(repo) or None,
|
||||
"identity": _text(identity) or None,
|
||||
"profile": _text(profile) or None,
|
||||
"lock_record_present": lock is not None,
|
||||
"recorded_claimant": None,
|
||||
"recorded_branch": None,
|
||||
"recorded_worktree": None,
|
||||
"lock_generation": None,
|
||||
"local_head_sha": _text(state.get("head_sha")) or None,
|
||||
"current_branch": _text(state.get("current_branch")) or None,
|
||||
"dirty_tracked_files": [],
|
||||
"untracked_files": [],
|
||||
"worktree_registered": worktree_registered,
|
||||
"remote_branch_exists": None,
|
||||
"remote_head_sha": None,
|
||||
"fast_forward_from_remote": None,
|
||||
"competing_open_prs": [],
|
||||
"file_hashes_verified": None,
|
||||
"hash_mismatches": [],
|
||||
# Recorded explicitly so no reader mistakes silence for a liveness
|
||||
# claim, and so the audit shows which records were left alone (AC23/AC24).
|
||||
"owner_pid_liveness_consulted": False,
|
||||
"workflow_lease_touched": False,
|
||||
"issue_lock_record_mutated": False,
|
||||
}
|
||||
|
||||
# ── declared shape ────────────────────────────────────────────────────
|
||||
if not branch:
|
||||
reasons.append("branch name not declared; fail closed")
|
||||
if not head:
|
||||
reasons.append(
|
||||
"expected_head not declared; publication must name the exact commit"
|
||||
)
|
||||
elif not _SHA_RE.match(head):
|
||||
reasons.append(
|
||||
f"expected_head '{head}' is not a full 40-character commit SHA; "
|
||||
"abbreviated or symbolic revisions are refused"
|
||||
)
|
||||
if not workspace:
|
||||
reasons.append("worktree path not declared; fail closed")
|
||||
|
||||
if branch:
|
||||
match = ISSUE_BRANCH_RE.match(branch)
|
||||
if not match:
|
||||
reasons.append(
|
||||
f"branch '{branch}' is not an issue-linked implementation branch "
|
||||
"((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
|
||||
)
|
||||
elif int(match.group(2)) != int(issue_number):
|
||||
reasons.append(
|
||||
f"branch '{branch}' does not carry issue number {issue_number}; "
|
||||
"fail closed"
|
||||
)
|
||||
if is_stable_ref(branch):
|
||||
reasons.append(
|
||||
f"refusing to publish stable branch '{branch}'; this operation "
|
||||
"publishes issue branches only"
|
||||
)
|
||||
|
||||
# ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
|
||||
if lock is None:
|
||||
reasons.append(
|
||||
"no durable issue-lock record for this issue; publication requires an "
|
||||
"existing recorded claim naming the caller, so this operation cannot "
|
||||
"be used to bypass the author lock"
|
||||
)
|
||||
else:
|
||||
lease = lock.get("work_lease")
|
||||
lease = lease if isinstance(lease, dict) else {}
|
||||
claimant = lease.get("claimant")
|
||||
claimant = claimant if isinstance(claimant, dict) else {}
|
||||
recorded_user = _text(claimant.get("username"))
|
||||
recorded_profile = _text(claimant.get("profile"))
|
||||
recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
|
||||
recorded_worktree = _realpath(
|
||||
_text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
|
||||
)
|
||||
evidence["recorded_claimant"] = {
|
||||
"username": recorded_user or None,
|
||||
"profile": recorded_profile or None,
|
||||
}
|
||||
evidence["recorded_branch"] = recorded_branch or None
|
||||
evidence["recorded_worktree"] = recorded_worktree
|
||||
try:
|
||||
evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
|
||||
except (TypeError, ValueError):
|
||||
evidence["lock_generation"] = 0
|
||||
|
||||
try:
|
||||
recorded_issue = int(lock.get("issue_number") or 0)
|
||||
except (TypeError, ValueError):
|
||||
recorded_issue = 0
|
||||
if recorded_issue != int(issue_number):
|
||||
reasons.append(
|
||||
f"durable lock records issue {lock.get('issue_number')}, not "
|
||||
f"{issue_number}; ambiguous ownership, fail closed"
|
||||
)
|
||||
for field, declared in (
|
||||
("remote", _text(remote)),
|
||||
("org", _text(org)),
|
||||
("repo", _text(repo)),
|
||||
):
|
||||
recorded = _text(lock.get(field))
|
||||
if recorded and declared and recorded != declared:
|
||||
reasons.append(
|
||||
f"durable lock records {field} '{recorded}' but the request "
|
||||
f"declares '{declared}'; repository mismatch, fail closed"
|
||||
)
|
||||
if recorded_branch and branch and recorded_branch != branch:
|
||||
reasons.append(
|
||||
f"durable lock records branch '{recorded_branch}' but the request "
|
||||
f"declares '{branch}'; fail closed"
|
||||
)
|
||||
if recorded_worktree and workspace and recorded_worktree != workspace:
|
||||
reasons.append(
|
||||
f"durable lock records worktree '{recorded_worktree}' but the "
|
||||
f"request declares '{workspace}'; fail closed"
|
||||
)
|
||||
if not recorded_user or not recorded_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record a claimant username and profile; "
|
||||
"ownership cannot be proven, fail closed"
|
||||
)
|
||||
else:
|
||||
if recorded_user != _text(identity):
|
||||
reasons.append(
|
||||
f"durable lock claimant '{recorded_user}' is not the acting "
|
||||
f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
|
||||
"fail closed"
|
||||
)
|
||||
if recorded_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"durable lock claimant profile '{recorded_profile}' is not "
|
||||
f"the active profile '{_text(profile) or '(unknown)'}'; "
|
||||
"fail closed"
|
||||
)
|
||||
|
||||
# ── worktree: registered, on-branch, clean, at the expected commit ────
|
||||
if worktree_registered is False:
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' is not listed in git worktree list; #713 "
|
||||
"requires a genuinely registered worktree, fail closed"
|
||||
)
|
||||
|
||||
current_branch = _text(state.get("current_branch"))
|
||||
if not current_branch:
|
||||
reasons.append("worktree branch could not be observed; fail closed")
|
||||
elif branch and current_branch != branch:
|
||||
reasons.append(
|
||||
f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
|
||||
)
|
||||
|
||||
porcelain = state.get("porcelain_status") or ""
|
||||
dirty_tracked = parse_dirty_tracked_files(porcelain)
|
||||
untracked = parse_untracked_files(porcelain)
|
||||
evidence["dirty_tracked_files"] = dirty_tracked
|
||||
evidence["untracked_files"] = untracked
|
||||
if dirty_tracked:
|
||||
reasons.append(
|
||||
"worktree has dirty tracked files, so the commit is not the whole of "
|
||||
f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
|
||||
"existing clean commit only; uncommitted content is out of scope"
|
||||
)
|
||||
if untracked:
|
||||
reasons.append(
|
||||
"worktree has untracked files that the commit does not carry: "
|
||||
f"{', '.join(untracked)}. Publishing would silently leave them "
|
||||
"behind; fail closed"
|
||||
)
|
||||
|
||||
local_head = _text(state.get("head_sha")).lower()
|
||||
if not local_head:
|
||||
reasons.append("local HEAD could not be observed; fail closed")
|
||||
elif head and local_head != head:
|
||||
reasons.append(
|
||||
f"worktree HEAD is {local_head} but the request declares {head}; the "
|
||||
"local commit changed since it was recorded, fail closed"
|
||||
)
|
||||
|
||||
# ── remote state ──────────────────────────────────────────────────────
|
||||
probe = remote_probe if isinstance(remote_probe, dict) else {}
|
||||
already_published = False
|
||||
if not probe.get("probe_ok"):
|
||||
reasons.append(
|
||||
"remote branch head could not be observed; publication must not "
|
||||
"proceed against an unknown remote state, fail closed"
|
||||
)
|
||||
reasons.extend(probe.get("reasons") or [])
|
||||
else:
|
||||
remote_exists = bool(probe.get("remote_branch_exists"))
|
||||
remote_head = _text(probe.get("remote_head_sha")).lower() or None
|
||||
evidence["remote_branch_exists"] = remote_exists
|
||||
evidence["remote_head_sha"] = remote_head
|
||||
if remote_exists and remote_head and head:
|
||||
if remote_head == head:
|
||||
already_published = True
|
||||
evidence["fast_forward_from_remote"] = True
|
||||
else:
|
||||
anc = ancestry if isinstance(ancestry, dict) else {}
|
||||
is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
|
||||
evidence["fast_forward_from_remote"] = is_anc
|
||||
if not is_anc:
|
||||
reasons.append(
|
||||
f"remote branch '{branch}' already exists at {remote_head}, "
|
||||
f"which is not an ancestor of {head}; publishing would "
|
||||
"discard or rewrite published history, fail closed"
|
||||
)
|
||||
reasons.extend(anc.get("reasons") or [])
|
||||
elif remote_exists and not remote_head:
|
||||
reasons.append(
|
||||
f"remote branch '{branch}' exists but its head could not be read; "
|
||||
"fail closed"
|
||||
)
|
||||
|
||||
# ── competing claims ──────────────────────────────────────────────────
|
||||
competing = [p for p in (competing_open_prs or ()) if p]
|
||||
evidence["competing_open_prs"] = list(competing)
|
||||
if competing:
|
||||
reasons.append(
|
||||
f"open pull request(s) {competing} already claim issue {issue_number} "
|
||||
"or this branch; ambiguous ownership, fail closed"
|
||||
)
|
||||
|
||||
# ── content verification before publication ───────────────────────────
|
||||
if expected_file_hashes:
|
||||
observed = (
|
||||
observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
|
||||
)
|
||||
mismatches: list[str] = []
|
||||
for path, expected_digest in dict(expected_file_hashes).items():
|
||||
actual = observed.get(path)
|
||||
if actual is None:
|
||||
mismatches.append(f"{path}: missing or unreadable in the worktree")
|
||||
elif _text(actual).lower() != _text(expected_digest).lower():
|
||||
mismatches.append(
|
||||
f"{path}: expected {expected_digest}, observed {actual}"
|
||||
)
|
||||
evidence["hash_mismatches"] = mismatches
|
||||
evidence["file_hashes_verified"] = not mismatches
|
||||
if mismatches:
|
||||
reasons.append(
|
||||
"declared content hashes do not match the worktree: "
|
||||
+ "; ".join(mismatches)
|
||||
+ ". Refusing to publish content that is not what was recorded"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"outcome": REFUSED,
|
||||
"publish_sanctioned": False,
|
||||
"already_published": False,
|
||||
"reasons": reasons,
|
||||
"evidence": evidence,
|
||||
}
|
||||
return {
|
||||
"outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
|
||||
# Idempotent retry: a remote head that already equals the assessed commit
|
||||
# needs no second push, so the caller verifies instead of acting.
|
||||
"publish_sanctioned": not already_published,
|
||||
"already_published": already_published,
|
||||
"reasons": [],
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def publish_commit_to_remote_branch(
|
||||
*,
|
||||
worktree_path: str,
|
||||
remote_name: str,
|
||||
branch_name: str,
|
||||
expected_head: str,
|
||||
) -> dict:
|
||||
"""Send exactly *expected_head* to ``refs/heads/<branch_name>``.
|
||||
|
||||
The refspec names the commit SHA explicitly rather than ``HEAD`` or the
|
||||
local branch, so what lands is the commit that was assessed and nothing
|
||||
else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
|
||||
git itself, the last of several independent guards against overwriting
|
||||
published history.
|
||||
"""
|
||||
path = _text(worktree_path)
|
||||
remote = _text(remote_name)
|
||||
branch = _text(branch_name)
|
||||
head = _text(expected_head)
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"pushed_ref": f"refs/heads/{branch}" if branch else None,
|
||||
"pushed_sha": head or None,
|
||||
"stderr": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not (path and remote and branch and head):
|
||||
result["reasons"].append(
|
||||
"publication requires a worktree path, remote, branch, and commit SHA"
|
||||
)
|
||||
return result
|
||||
|
||||
refspec = f"{head}:refs/heads/{branch}"
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "push", remote, refspec],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
result["reasons"].append(f"publication could not run: {exc}")
|
||||
return result
|
||||
|
||||
if res.returncode != 0:
|
||||
# Redact before surfacing: failures can echo credentialed remote URLs.
|
||||
result["stderr"] = redact_command(res.stderr or "")
|
||||
result["reasons"].append(
|
||||
f"publication of {head} to '{branch}' on remote '{remote}' failed"
|
||||
)
|
||||
return result
|
||||
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
|
||||
def verify_published_head(
|
||||
*, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
|
||||
) -> dict:
|
||||
"""Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
|
||||
probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
|
||||
head = _text(expected_head).lower()
|
||||
observed = _text(probe.get("remote_head_sha")).lower() or None
|
||||
verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
|
||||
reasons: list[str] = list(probe.get("reasons") or [])
|
||||
if probe.get("probe_ok") and not verified:
|
||||
reasons.append(
|
||||
"read-after-write verification failed: remote head is "
|
||||
f"{observed or '(absent)'}, expected {head}"
|
||||
)
|
||||
return {
|
||||
"verified": verified,
|
||||
"remote_head_sha": observed,
|
||||
"expected_head": head or None,
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
+856
-4
@@ -30,8 +30,9 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
import dependency_graph
|
||||
import gitea_audit
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
SCHEMA_VERSION = 5
|
||||
|
||||
# Assignable work kinds only — raw monitoring incidents are never work items.
|
||||
WORK_KINDS = frozenset({"issue", "pr"})
|
||||
@@ -177,6 +178,51 @@ CREATE TABLE IF NOT EXISTS dependency_edges (
|
||||
)
|
||||
);
|
||||
|
||||
-- Durable MCP session checkpoints (#660, umbrella #655 child; #628 handoff
|
||||
-- goal). A restart otherwise loses session identity, stage, lease ownership,
|
||||
-- and next action, forcing human reconstruction. Each row is the *current*
|
||||
-- recoverable state of one session's work on one work unit; stage transitions
|
||||
-- upsert the row and audit the change to ``events``. Creating the table is the
|
||||
-- v4->v5 migration: additive, idempotent, and it never touches prior tables.
|
||||
--
|
||||
-- ``lease_id`` / ``assignment_id`` are recorded as soft references (plain TEXT,
|
||||
-- no enforced FK) exactly as dependency_edges references issues/PRs by number:
|
||||
-- a checkpoint is a recovery artifact that must survive the deletion of the
|
||||
-- lease it names, so a hard FK would fail the pre-drain write the issue
|
||||
-- requires to succeed. ``work_number`` uses 0 (never a real issue/PR number)
|
||||
-- as the "session-level, no specific work" sentinel so UNIQUE is NULL-safe.
|
||||
-- Every free-text / JSON field is redaction-filtered before storage (AC4).
|
||||
CREATE TABLE IF NOT EXISTS session_checkpoints (
|
||||
checkpoint_id TEXT PRIMARY KEY,
|
||||
remote TEXT NOT NULL,
|
||||
org TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
provider_identity TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
work_kind TEXT NOT NULL DEFAULT '' CHECK (work_kind IN ('issue', 'pr', '')),
|
||||
work_number INTEGER NOT NULL DEFAULT 0,
|
||||
worktree_path TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
head_sha TEXT NOT NULL DEFAULT '',
|
||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||
lease_id TEXT NOT NULL DEFAULT '',
|
||||
assignment_id TEXT NOT NULL DEFAULT '',
|
||||
workflow_stage TEXT NOT NULL DEFAULT '',
|
||||
last_completed_action TEXT NOT NULL DEFAULT '',
|
||||
current_operation TEXT NOT NULL DEFAULT '',
|
||||
pending_mutation TEXT NOT NULL DEFAULT '',
|
||||
evidence TEXT NOT NULL DEFAULT '{}',
|
||||
blocker TEXT NOT NULL DEFAULT '',
|
||||
next_valid_action TEXT NOT NULL DEFAULT '',
|
||||
recovery_instructions TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_schema_version INTEGER NOT NULL DEFAULT 5,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (remote, org, repo, session_id, work_kind, work_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_work_status ON leases(work_item_id, status);
|
||||
-- Reverse lookup ("what waits on this target") is the query automatic
|
||||
-- resumption needs, so it gets its own index alongside the forward one.
|
||||
@@ -186,6 +232,40 @@ CREATE INDEX IF NOT EXISTS idx_dependency_edges_target
|
||||
ON dependency_edges(remote, org, repo, target_kind, target_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_assignments_session ON assignments(session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_gitea ON incident_links(gitea_org, gitea_repo, gitea_issue_number);
|
||||
-- Resume queries hit by session (what was this session doing) and by work unit
|
||||
-- (who was checkpointed on this issue/PR), so both get an index.
|
||||
CREATE INDEX IF NOT EXISTS idx_session_checkpoints_session
|
||||
ON session_checkpoints(remote, org, repo, session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_checkpoints_work
|
||||
ON session_checkpoints(remote, org, repo, work_kind, work_number);
|
||||
|
||||
-- Model usage, token cost, latency, and performance events (#651)
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
remote TEXT NOT NULL DEFAULT 'dadeschools',
|
||||
org TEXT NOT NULL DEFAULT '',
|
||||
repo TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'unknown',
|
||||
model TEXT NOT NULL DEFAULT 'unknown',
|
||||
issue_number INTEGER,
|
||||
pr_number INTEGER,
|
||||
stage TEXT NOT NULL DEFAULT 'unknown',
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
estimated_cost_usd REAL,
|
||||
latency_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_scope ON usage_events(remote, org, repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_role_model ON usage_events(role, model);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_stage ON usage_events(stage);
|
||||
"""
|
||||
|
||||
|
||||
@@ -339,6 +419,7 @@ class ControlPlaneDB:
|
||||
self._migrate_incident_links_null_scope(conn)
|
||||
self._migrate_lease_lifecycle_columns(conn)
|
||||
self._migrate_session_ownership_columns(conn)
|
||||
self._migrate_usage_events_table(conn)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
@@ -518,6 +599,207 @@ class ControlPlaneDB:
|
||||
f"UPDATE incident_links SET {col} = '' WHERE {col} IS NULL"
|
||||
)
|
||||
|
||||
def _migrate_usage_events_table(self, conn: sqlite3.Connection) -> None:
|
||||
"""Create usage_events table and indexes if they do not exist (#651)."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
remote TEXT NOT NULL DEFAULT 'dadeschools',
|
||||
org TEXT NOT NULL DEFAULT '',
|
||||
repo TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'unknown',
|
||||
model TEXT NOT NULL DEFAULT 'unknown',
|
||||
issue_number INTEGER,
|
||||
pr_number INTEGER,
|
||||
stage TEXT NOT NULL DEFAULT 'unknown',
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
estimated_cost_usd REAL,
|
||||
latency_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_events_scope ON usage_events(remote, org, repo);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_events_role_model ON usage_events(role, model);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_events_stage ON usage_events(stage);")
|
||||
|
||||
# #651 retention: cap growth so unauthenticated or high-volume ingest
|
||||
# cannot DoS the control-plane DB (PR #876 F3). Applied after every write.
|
||||
USAGE_EVENTS_MAX_ROWS = 10_000
|
||||
USAGE_EVENTS_RETENTION_DAYS = 90
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
org: str = "",
|
||||
repo: str = "",
|
||||
project_id: str | None = None,
|
||||
role: str = "unknown",
|
||||
model: str = "unknown",
|
||||
issue_number: int | None = None,
|
||||
pr_number: int | None = None,
|
||||
stage: str = "unknown",
|
||||
input_tokens: int | None = None,
|
||||
output_tokens: int | None = None,
|
||||
total_tokens: int | None = None,
|
||||
estimated_cost_usd: float | None = None,
|
||||
latency_ms: int | None = None,
|
||||
duration_ms: int | None = None,
|
||||
status: str = "success",
|
||||
metadata: str | dict[str, Any] | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> int:
|
||||
"""Record a model usage, token cost, latency, or stage performance event (#651)."""
|
||||
ts = created_at or _ts()
|
||||
meta_str: str | None = None
|
||||
if metadata is not None:
|
||||
from webui import console_redaction
|
||||
redacted_meta = console_redaction.redact_payload(metadata)
|
||||
if isinstance(redacted_meta, str):
|
||||
meta_str = redacted_meta
|
||||
else:
|
||||
try:
|
||||
meta_str = json.dumps(redacted_meta, default=str)
|
||||
except Exception:
|
||||
meta_str = str(redacted_meta)
|
||||
|
||||
if total_tokens is None and (input_tokens is not None or output_tokens is not None):
|
||||
total_tokens = (input_tokens or 0) + (output_tokens or 0)
|
||||
|
||||
with self._tx(immediate=True) as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO usage_events (
|
||||
session_id, remote, org, repo, project_id, role, model,
|
||||
issue_number, pr_number, stage, input_tokens, output_tokens,
|
||||
total_tokens, estimated_cost_usd, latency_ms, duration_ms,
|
||||
status, metadata, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
session_id,
|
||||
remote,
|
||||
org,
|
||||
repo,
|
||||
project_id,
|
||||
role,
|
||||
model,
|
||||
issue_number,
|
||||
pr_number,
|
||||
stage,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
estimated_cost_usd,
|
||||
latency_ms,
|
||||
duration_ms,
|
||||
status,
|
||||
meta_str,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
usage_id = cursor.lastrowid
|
||||
self._enforce_usage_events_retention(conn)
|
||||
return usage_id
|
||||
|
||||
def _enforce_usage_events_retention(self, conn: sqlite3.Connection) -> None:
|
||||
"""Drop aged and excess usage_events rows (PR #876 F3)."""
|
||||
# Age-based: ISO-8601 UTC timestamps compare lexicographically.
|
||||
cutoff = (
|
||||
datetime.now(timezone.utc)
|
||||
- timedelta(days=int(self.USAGE_EVENTS_RETENTION_DAYS))
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
conn.execute(
|
||||
"DELETE FROM usage_events WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
# Count-based: keep the newest USAGE_EVENTS_MAX_ROWS by usage_id.
|
||||
max_rows = int(self.USAGE_EVENTS_MAX_ROWS)
|
||||
if max_rows > 0:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM usage_events
|
||||
WHERE usage_id NOT IN (
|
||||
SELECT usage_id FROM usage_events
|
||||
ORDER BY usage_id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(max_rows,),
|
||||
)
|
||||
|
||||
def query_usage_events(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
project_id: str | None = None,
|
||||
role: str | None = None,
|
||||
model: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
pr_number: int | None = None,
|
||||
stage: str | None = None,
|
||||
session_id: str | None = None,
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query stored usage events matching filters (#651)."""
|
||||
conditions = []
|
||||
params = []
|
||||
if remote:
|
||||
conditions.append("remote = ?")
|
||||
params.append(remote)
|
||||
if org:
|
||||
conditions.append("org = ?")
|
||||
params.append(org)
|
||||
if repo:
|
||||
conditions.append("repo = ?")
|
||||
params.append(repo)
|
||||
if project_id:
|
||||
conditions.append("project_id = ?")
|
||||
params.append(project_id)
|
||||
if role:
|
||||
conditions.append("role = ?")
|
||||
params.append(role)
|
||||
if model:
|
||||
conditions.append("model = ?")
|
||||
params.append(model)
|
||||
if issue_number is not None:
|
||||
conditions.append("issue_number = ?")
|
||||
params.append(issue_number)
|
||||
if pr_number is not None:
|
||||
conditions.append("pr_number = ?")
|
||||
params.append(pr_number)
|
||||
if stage:
|
||||
conditions.append("stage = ?")
|
||||
params.append(stage)
|
||||
if session_id:
|
||||
conditions.append("session_id = ?")
|
||||
params.append(session_id)
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
sql = f"""
|
||||
SELECT * FROM usage_events
|
||||
{where_clause}
|
||||
ORDER BY usage_id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params.extend([limit, offset])
|
||||
|
||||
with self._tx(immediate=False) as conn:
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
# ── sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_session(
|
||||
@@ -599,6 +881,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(
|
||||
@@ -1637,11 +1948,13 @@ class ControlPlaneDB:
|
||||
provenance: dict[str, Any] | None = None,
|
||||
lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
"""Transfer or refresh a lease with provenance (#601).
|
||||
"""Transfer or refresh a lease with provenance (#601 / #843).
|
||||
|
||||
* Same owner + active → refresh (owner-resume).
|
||||
* Cross-role handoff pending + matching required role → atomic consume
|
||||
(even while the allocating controller session still "owns" the lease).
|
||||
* Expired/abandoned/released → create new assignment+lease with provenance.
|
||||
* Active foreign → raise ForeignLeaseError (never silent steal).
|
||||
* Active foreign (non-handoff) → raise ForeignLeaseError (never silent steal).
|
||||
"""
|
||||
now = _utc_now()
|
||||
now_s = _ts(now)
|
||||
@@ -1677,7 +1990,35 @@ class ControlPlaneDB:
|
||||
status = "expired"
|
||||
|
||||
owner = lease["session_id"]
|
||||
if status == "active" and owner != adopter_session_id:
|
||||
# Parse durable provenance for cross-role handoff consume (#843).
|
||||
lease_prov: dict[str, Any] = {}
|
||||
if "provenance_json" in lease.keys() and lease["provenance_json"]:
|
||||
try:
|
||||
loaded = json.loads(lease["provenance_json"])
|
||||
if isinstance(loaded, dict):
|
||||
lease_prov = loaded
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
lease_prov = {}
|
||||
handoff_pending = bool(lease_prov.get("cross_role_handoff")) and (
|
||||
str(lease_prov.get("handoff_status") or "pending").strip().lower()
|
||||
== "pending"
|
||||
)
|
||||
already_adopted = bool(
|
||||
(lease["adopted_by_session_id"] if "adopted_by_session_id" in lease.keys() else None)
|
||||
or lease_prov.get("adopted_by_session_id")
|
||||
)
|
||||
required_role = str(
|
||||
lease_prov.get("required_role") or lease["role"] or ""
|
||||
).strip().lower()
|
||||
adopter_role = (role or "").strip().lower()
|
||||
cross_role_consume = (
|
||||
handoff_pending
|
||||
and not already_adopted
|
||||
and status == "active"
|
||||
and owner != adopter_session_id
|
||||
)
|
||||
|
||||
if status == "active" and owner != adopter_session_id and not cross_role_consume:
|
||||
raise ForeignLeaseError(
|
||||
f"cannot adopt active foreign lease {lease_id} owned by {owner}"
|
||||
)
|
||||
@@ -1761,6 +2102,142 @@ class ControlPlaneDB:
|
||||
"reasons": ["owner-resume: refreshed lease with provenance"],
|
||||
}
|
||||
|
||||
# #843: controller→required-role handoff consume (atomic, same lease_id)
|
||||
if cross_role_consume:
|
||||
if not required_role:
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff lease {lease_id} missing required_role"
|
||||
)
|
||||
if adopter_role != required_role:
|
||||
raise ForeignLeaseError(
|
||||
f"wrong role for cross-role handoff consume: "
|
||||
f"required={required_role} adopter={adopter_role or 'none'} "
|
||||
f"(fail closed)"
|
||||
)
|
||||
# CAS: only transfer if still owned by allocating session and unadopted
|
||||
cols = self._lease_columns(conn)
|
||||
adopted_col_null = (
|
||||
"(adopted_by_session_id IS NULL OR adopted_by_session_id = '')"
|
||||
if "adopted_by_session_id" in cols
|
||||
else "1=1"
|
||||
)
|
||||
cas = conn.execute(
|
||||
f"""
|
||||
UPDATE leases
|
||||
SET session_id = ?,
|
||||
heartbeat_at = ?,
|
||||
expires_at = ?,
|
||||
phase = ?,
|
||||
role = ?
|
||||
WHERE lease_id = ?
|
||||
AND status = 'active'
|
||||
AND session_id = ?
|
||||
AND {adopted_col_null}
|
||||
""",
|
||||
(
|
||||
adopter_session_id,
|
||||
now_s,
|
||||
expires,
|
||||
"adopted",
|
||||
required_role,
|
||||
lease_id,
|
||||
owner,
|
||||
),
|
||||
)
|
||||
if cas.rowcount != 1:
|
||||
raise ForeignLeaseError(
|
||||
f"cross-role handoff consume lost race for lease {lease_id} "
|
||||
"(already adopted or no longer pending; fail closed)"
|
||||
)
|
||||
if "adopted_from_session_id" in cols:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE leases
|
||||
SET adopted_from_session_id = ?, adopted_by_session_id = ?
|
||||
WHERE lease_id = ?
|
||||
""",
|
||||
(owner, adopter_session_id, lease_id),
|
||||
)
|
||||
if "worktree_path" in cols and worktree_path:
|
||||
conn.execute(
|
||||
"UPDATE leases SET worktree_path = ? WHERE lease_id = ?",
|
||||
(worktree_path, lease_id),
|
||||
)
|
||||
if "owner_pid" in cols and owner_pid is not None:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = ? WHERE lease_id = ?",
|
||||
(owner_pid, lease_id),
|
||||
)
|
||||
if "expected_head_sha" in cols and expected_head_sha:
|
||||
conn.execute(
|
||||
"UPDATE leases SET expected_head_sha = ? WHERE lease_id = ?",
|
||||
(expected_head_sha, lease_id),
|
||||
)
|
||||
# Merge handoff provenance + caller provenance
|
||||
merged = dict(lease_prov)
|
||||
merged.update(provenance or {})
|
||||
merged["cross_role_handoff"] = True
|
||||
merged["handoff_status"] = "adopted"
|
||||
merged["adopted_from_session_id"] = owner
|
||||
merged["adopted_by_session_id"] = adopter_session_id
|
||||
merged["required_role"] = required_role
|
||||
if "provenance_json" in cols:
|
||||
conn.execute(
|
||||
"UPDATE leases SET provenance_json = ? WHERE lease_id = ?",
|
||||
(json.dumps(merged), lease_id),
|
||||
)
|
||||
# Transfer active assignment ownership atomically
|
||||
asn_cas = conn.execute(
|
||||
"""
|
||||
UPDATE assignments
|
||||
SET session_id = ?, role = ?
|
||||
WHERE lease_id = ? AND status = 'active' AND session_id = ?
|
||||
""",
|
||||
(adopter_session_id, required_role, lease_id, owner),
|
||||
)
|
||||
if asn_cas.rowcount < 1:
|
||||
# Fail closed: assignment must move with the lease
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff: no active assignment for lease {lease_id} "
|
||||
f"owned by {owner}"
|
||||
)
|
||||
lease2 = conn.execute(
|
||||
"SELECT * FROM leases WHERE lease_id = ?", (lease_id,)
|
||||
).fetchone()
|
||||
asn = conn.execute(
|
||||
"""
|
||||
SELECT * FROM assignments
|
||||
WHERE lease_id = ? AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(lease_id,),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (?, 'lease_adopted', ?, ?)
|
||||
""",
|
||||
(
|
||||
lease["work_item_id"],
|
||||
f"cross-role handoff: {adopter_session_id} consumed "
|
||||
f"{lease_id} from {owner} as {required_role}",
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"outcome": "adopted_cross_role_handoff",
|
||||
"lease": dict(lease2) if lease2 else dict(lease),
|
||||
"assignment": dict(asn) if asn else None,
|
||||
"reasons": [
|
||||
"cross-role handoff: independent required-role worker consumed "
|
||||
"controller allocation without abandonment"
|
||||
],
|
||||
"adopted_by_session_id": adopter_session_id,
|
||||
"adopted_from_session_id": owner,
|
||||
"required_role": required_role,
|
||||
"handoff_status": "adopted",
|
||||
}
|
||||
|
||||
# Non-active: create new lease + assignment (transfer)
|
||||
new_lease_id = f"lease-{uuid.uuid4().hex[:16]}"
|
||||
new_asn_id = f"asn-{uuid.uuid4().hex[:16]}"
|
||||
@@ -2173,3 +2650,378 @@ class ControlPlaneDB:
|
||||
edge["prior_state"] = prior_state
|
||||
edge["state_changed"] = prior_state != state_norm
|
||||
return edge
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session checkpoints (#660) — durable, redacted, reconcile-on-boot.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Free-text columns that carry human/agent-authored strings. Each is
|
||||
# redaction-filtered before storage so an accidentally pasted token or
|
||||
# credential URL can never land in a checkpoint (#660 AC4).
|
||||
_CHECKPOINT_TEXT_FIELDS: tuple[str, ...] = (
|
||||
"provider_identity",
|
||||
"role",
|
||||
"worktree_path",
|
||||
"branch",
|
||||
"head_sha",
|
||||
"lease_id",
|
||||
"assignment_id",
|
||||
"workflow_stage",
|
||||
"last_completed_action",
|
||||
"current_operation",
|
||||
"blocker",
|
||||
"next_valid_action",
|
||||
"recovery_instructions",
|
||||
"status",
|
||||
)
|
||||
|
||||
# JSON columns whose *decoded* structure is redacted recursively.
|
||||
_CHECKPOINT_JSON_FIELDS: tuple[str, ...] = (
|
||||
"capabilities",
|
||||
"pending_mutation",
|
||||
"evidence",
|
||||
)
|
||||
|
||||
# Minimum a checkpoint must carry to be a usable recovery record. The drain
|
||||
# gate writes with ``require_complete=True``; an incomplete write fails
|
||||
# closed so drain cannot complete on a checkpoint that can't resume (#660
|
||||
# "fail closed if checkpoint incomplete during drain").
|
||||
REQUIRED_CHECKPOINT_FIELDS_FOR_DRAIN: tuple[str, ...] = (
|
||||
"session_id",
|
||||
"role",
|
||||
"workflow_stage",
|
||||
"next_valid_action",
|
||||
"recovery_instructions",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def checkpoint_completeness(cls, record: dict[str, Any]) -> list[str]:
|
||||
"""Return the drain-required fields that are missing/blank in *record*.
|
||||
|
||||
Pure (no DB). An empty list means the record is drain-complete.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
for field in cls.REQUIRED_CHECKPOINT_FIELDS_FOR_DRAIN:
|
||||
if not str(record.get(field) or "").strip():
|
||||
missing.append(field)
|
||||
return missing
|
||||
|
||||
def write_session_checkpoint(
|
||||
self,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
session_id: str,
|
||||
provider_identity: str = "",
|
||||
role: str = "",
|
||||
work_kind: str | None = None,
|
||||
work_number: int | None = None,
|
||||
worktree_path: str = "",
|
||||
branch: str = "",
|
||||
head_sha: str = "",
|
||||
capabilities: Any = None,
|
||||
lease_id: str = "",
|
||||
assignment_id: str = "",
|
||||
workflow_stage: str = "",
|
||||
last_completed_action: str = "",
|
||||
current_operation: str = "",
|
||||
pending_mutation: Any = None,
|
||||
evidence: Any = None,
|
||||
blocker: str = "",
|
||||
next_valid_action: str = "",
|
||||
recovery_instructions: str = "",
|
||||
status: str = "active",
|
||||
require_complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert the current checkpoint for one session's work on one unit.
|
||||
|
||||
Keyed by ``(remote, org, repo, session_id, work_kind, work_number)`` so
|
||||
a stage transition refreshes the single current row (transitions are
|
||||
audited to ``events``) rather than appending unbounded history — the
|
||||
row is *resumable state*, matching the dependency-edge current-state
|
||||
model.
|
||||
|
||||
Every string / JSON field is redacted via ``gitea_audit.redact`` before
|
||||
it touches the DB. With ``require_complete=True`` (the drain gate path)
|
||||
a record missing any drain-required field raises ``ControlPlaneError``
|
||||
and writes nothing, so drain cannot proceed on an unrecoverable record.
|
||||
"""
|
||||
if not str(session_id or "").strip():
|
||||
raise ControlPlaneError("session_id is required for a checkpoint (fail closed)")
|
||||
|
||||
# Normalize the work-unit key. Absent/blank work collapses to the
|
||||
# ('', 0) session-level sentinel so UNIQUE stays NULL-safe.
|
||||
if work_kind and str(work_kind).strip():
|
||||
kind_norm = dependency_graph.normalize_work_kind(work_kind)
|
||||
number_norm = int(work_number) if work_number is not None else 0
|
||||
else:
|
||||
kind_norm = ""
|
||||
number_norm = 0
|
||||
|
||||
# Assemble, then redact the whole record in one pass.
|
||||
raw_record: dict[str, Any] = {
|
||||
"provider_identity": provider_identity or "",
|
||||
"role": role or "",
|
||||
"worktree_path": worktree_path or "",
|
||||
"branch": branch or "",
|
||||
"head_sha": head_sha or "",
|
||||
"lease_id": lease_id or "",
|
||||
"assignment_id": assignment_id or "",
|
||||
"workflow_stage": workflow_stage or "",
|
||||
"last_completed_action": last_completed_action or "",
|
||||
"current_operation": current_operation or "",
|
||||
"blocker": blocker or "",
|
||||
"next_valid_action": next_valid_action or "",
|
||||
"recovery_instructions": recovery_instructions or "",
|
||||
"status": (status or "active"),
|
||||
"session_id": session_id,
|
||||
"capabilities": capabilities if capabilities is not None else [],
|
||||
"pending_mutation": pending_mutation if pending_mutation is not None else {},
|
||||
"evidence": evidence if evidence is not None else {},
|
||||
}
|
||||
clean = gitea_audit.redact(raw_record)
|
||||
|
||||
if require_complete:
|
||||
missing = self.checkpoint_completeness(clean)
|
||||
if missing:
|
||||
raise ControlPlaneError(
|
||||
"checkpoint incomplete for drain; missing "
|
||||
f"{', '.join(missing)} (fail closed)"
|
||||
)
|
||||
|
||||
capabilities_json = json.dumps(clean.get("capabilities") or [])
|
||||
pending_json = json.dumps(clean.get("pending_mutation") or {})
|
||||
evidence_json = json.dumps(clean.get("evidence") or {})
|
||||
now_s = _ts()
|
||||
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"""
|
||||
SELECT * FROM session_checkpoints
|
||||
WHERE remote = ? AND org = ? AND repo = ?
|
||||
AND session_id = ? AND work_kind = ? AND work_number = ?
|
||||
""",
|
||||
(remote, org, repo, session_id, kind_norm, number_norm),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
checkpoint_id = uuid.uuid4().hex
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO session_checkpoints(
|
||||
checkpoint_id, remote, org, repo, session_id,
|
||||
provider_identity, role, work_kind, work_number,
|
||||
worktree_path, branch, head_sha, capabilities,
|
||||
lease_id, assignment_id, workflow_stage,
|
||||
last_completed_action, current_operation,
|
||||
pending_mutation, evidence, blocker, next_valid_action,
|
||||
recovery_instructions, checkpoint_schema_version,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
checkpoint_id, remote, org, repo, session_id,
|
||||
clean["provider_identity"], clean["role"], kind_norm,
|
||||
number_norm, clean["worktree_path"], clean["branch"],
|
||||
clean["head_sha"], capabilities_json, clean["lease_id"],
|
||||
clean["assignment_id"], clean["workflow_stage"],
|
||||
clean["last_completed_action"], clean["current_operation"],
|
||||
pending_json, evidence_json, clean["blocker"],
|
||||
clean["next_valid_action"], clean["recovery_instructions"],
|
||||
SCHEMA_VERSION, clean["status"], now_s, now_s,
|
||||
),
|
||||
)
|
||||
else:
|
||||
checkpoint_id = str(existing["checkpoint_id"])
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE session_checkpoints
|
||||
SET provider_identity = ?, role = ?, worktree_path = ?,
|
||||
branch = ?, head_sha = ?, capabilities = ?,
|
||||
lease_id = ?, assignment_id = ?, workflow_stage = ?,
|
||||
last_completed_action = ?, current_operation = ?,
|
||||
pending_mutation = ?, evidence = ?, blocker = ?,
|
||||
next_valid_action = ?, recovery_instructions = ?,
|
||||
checkpoint_schema_version = ?, status = ?,
|
||||
updated_at = ?
|
||||
WHERE checkpoint_id = ?
|
||||
""",
|
||||
(
|
||||
clean["provider_identity"], clean["role"],
|
||||
clean["worktree_path"], clean["branch"], clean["head_sha"],
|
||||
capabilities_json, clean["lease_id"], clean["assignment_id"],
|
||||
clean["workflow_stage"], clean["last_completed_action"],
|
||||
clean["current_operation"], pending_json, evidence_json,
|
||||
clean["blocker"], clean["next_valid_action"],
|
||||
clean["recovery_instructions"], SCHEMA_VERSION,
|
||||
clean["status"], now_s, checkpoint_id,
|
||||
),
|
||||
)
|
||||
prior_stage = str(existing["workflow_stage"] or "")
|
||||
new_stage = str(clean["workflow_stage"] or "")
|
||||
if prior_stage != new_stage:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (NULL, 'session_checkpoint_stage_change', ?, ?)
|
||||
""",
|
||||
(
|
||||
f"checkpoint {checkpoint_id} session {session_id} "
|
||||
f"stage {prior_stage or '(none)'} -> "
|
||||
f"{new_stage or '(none)'}",
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM session_checkpoints WHERE checkpoint_id = ?",
|
||||
(checkpoint_id,),
|
||||
).fetchone()
|
||||
return self._session_checkpoint_row(row) or {}
|
||||
|
||||
@staticmethod
|
||||
def _session_checkpoint_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
"""Return a stored checkpoint as a plain dict with JSON fields decoded."""
|
||||
if row is None:
|
||||
return None
|
||||
record = dict(row)
|
||||
for field, empty in (
|
||||
("capabilities", []),
|
||||
("pending_mutation", {}),
|
||||
("evidence", {}),
|
||||
):
|
||||
raw = record.get(field)
|
||||
try:
|
||||
record[field] = json.loads(raw) if raw else empty
|
||||
except (TypeError, ValueError):
|
||||
# A row written by an older/foreign writer must not break reads.
|
||||
record[field] = {"unparsed": str(raw)}
|
||||
return record
|
||||
|
||||
def get_session_checkpoint(
|
||||
self,
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
session_id: str,
|
||||
work_kind: str | None = None,
|
||||
work_number: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the current checkpoint for one session's work unit, or None."""
|
||||
if work_kind and str(work_kind).strip():
|
||||
kind_norm = dependency_graph.normalize_work_kind(work_kind)
|
||||
number_norm = int(work_number) if work_number is not None else 0
|
||||
else:
|
||||
kind_norm = ""
|
||||
number_norm = 0
|
||||
with self._tx(immediate=False) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM session_checkpoints
|
||||
WHERE remote = ? AND org = ? AND repo = ?
|
||||
AND session_id = ? AND work_kind = ? AND work_number = ?
|
||||
""",
|
||||
(remote, org, repo, session_id, kind_norm, number_norm),
|
||||
).fetchone()
|
||||
return self._session_checkpoint_row(row)
|
||||
|
||||
def list_session_checkpoints(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
work_kind: str | None = None,
|
||||
work_number: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return stored checkpoints, filtered. Newest updated first."""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if remote:
|
||||
clauses.append("remote = ?")
|
||||
params.append(remote)
|
||||
if org:
|
||||
clauses.append("org = ?")
|
||||
params.append(org)
|
||||
if repo:
|
||||
clauses.append("repo = ?")
|
||||
params.append(repo)
|
||||
if session_id:
|
||||
clauses.append("session_id = ?")
|
||||
params.append(session_id)
|
||||
if work_kind:
|
||||
clauses.append("work_kind = ?")
|
||||
params.append(dependency_graph.normalize_work_kind(work_kind))
|
||||
if work_number is not None:
|
||||
clauses.append("work_number = ?")
|
||||
params.append(int(work_number))
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
|
||||
sql = "SELECT * FROM session_checkpoints"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY updated_at DESC LIMIT ?"
|
||||
params.append(int(limit))
|
||||
|
||||
with self._tx(immediate=False) as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [
|
||||
record
|
||||
for record in (self._session_checkpoint_row(r) for r in rows)
|
||||
if record
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def reconcile_session_checkpoint(
|
||||
checkpoint: dict[str, Any],
|
||||
*,
|
||||
live_head_sha: str | None = None,
|
||||
live_lease_active: bool | None = None,
|
||||
live_lease_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Diagnose a stored checkpoint against live Git/Gitea/lease state.
|
||||
|
||||
Pure (no DB) and **never restores** — it returns a diagnosis and the
|
||||
caller decides. A head that no longer matches, or a lease that is gone
|
||||
or reassigned, marks the checkpoint stale so resumption reconciles
|
||||
instead of blindly restoring stale ownership (#660 AC3).
|
||||
|
||||
A ``None`` live input means "unknown, not checked" and never flags a
|
||||
mismatch on its own.
|
||||
"""
|
||||
stored_head = str(checkpoint.get("head_sha") or "")
|
||||
stored_lease = str(checkpoint.get("lease_id") or "")
|
||||
|
||||
head_mismatch = bool(
|
||||
live_head_sha is not None
|
||||
and stored_head
|
||||
and stored_head != str(live_head_sha)
|
||||
)
|
||||
lease_mismatch = False
|
||||
if stored_lease:
|
||||
if live_lease_active is False:
|
||||
lease_mismatch = True
|
||||
elif live_lease_id is not None and str(live_lease_id) != stored_lease:
|
||||
lease_mismatch = True
|
||||
|
||||
stale = head_mismatch or lease_mismatch
|
||||
return {
|
||||
"checkpoint_id": checkpoint.get("checkpoint_id"),
|
||||
"session_id": checkpoint.get("session_id"),
|
||||
"stale": stale,
|
||||
"head_mismatch": head_mismatch,
|
||||
"lease_mismatch": lease_mismatch,
|
||||
"stored_head_sha": stored_head,
|
||||
"live_head_sha": None if live_head_sha is None else str(live_head_sha),
|
||||
"stored_lease_id": stored_lease,
|
||||
"live_lease_id": None if live_lease_id is None else str(live_lease_id),
|
||||
"reconcile_action": "reconcile_required" if stale else "safe_to_resume",
|
||||
}
|
||||
|
||||
@@ -247,7 +247,8 @@ def bootstrap_permits_control_checkout(
|
||||
"""
|
||||
if not isinstance(assessment, dict):
|
||||
return False
|
||||
if not is_create_issue_task(task):
|
||||
import author_issue_bootstrap
|
||||
if not is_create_issue_task(task) and not author_issue_bootstrap.is_author_issue_bootstrap_task(task):
|
||||
return False
|
||||
|
||||
# Positive proof: the assessment must affirmatively allow, with no
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,13 @@
|
||||
|
||||
| Capability | Notes |
|
||||
|------------|--------|
|
||||
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
|
||||
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links`, `session_checkpoints` |
|
||||
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
|
||||
| Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head |
|
||||
| Heartbeat / release / expire | Lease lifecycle helpers |
|
||||
| Terminal-lock index | Routing signal for #600 (terminal path first) |
|
||||
| `incident_links` | Provider-neutral link model for #612 — **not** assignable work; scope keys NULL-safe |
|
||||
| `session_checkpoints` | Durable session resume state for #660 — redacted at write, reconciled (never restored) on boot |
|
||||
|
||||
## Hard rules (enforced in code)
|
||||
|
||||
@@ -92,6 +93,44 @@ Module: `incident_bridge.py` · MCP tools: `gitea_observability_*`
|
||||
- Provider tokens never appear in issue bodies, links, or tool results.
|
||||
- Allocator sees bridge work only after a Gitea issue exists.
|
||||
|
||||
## Session checkpoints (#660)
|
||||
|
||||
Module: `control_plane_db.py` · Table: `session_checkpoints` · Parent **#655** · Soft-depends **#659** · Vision **#652** · Roadmap **#653**
|
||||
|
||||
Workflow state that lived only in MCP process memory or chat did not survive a restart, so session identity, stage, lease ownership, and the next valid action had to be reconstructed by hand. The `session_checkpoints` table makes that state durable.
|
||||
|
||||
### Schema version
|
||||
|
||||
Rows carry `checkpoint_schema_version` (currently **5**, tracking the module-level `SCHEMA_VERSION`), so a reader can tell which field set a record was written under. The row identity is
|
||||
`UNIQUE (remote, org, repo, session_id, work_kind, work_number)` — one current-state row per session per work unit, upserted rather than appended. A session-level checkpoint that is not bound to an issue or PR uses the `work_number = 0` sentinel with an empty `work_kind`.
|
||||
|
||||
| Group | Columns |
|
||||
|-------|---------|
|
||||
| Identity | `session_id`, `provider_identity`, `role`, `remote`/`org`/`repo` |
|
||||
| Work unit | `work_kind` (`issue`/`pr`/empty), `work_number`, `worktree_path`, `branch`, `head_sha` |
|
||||
| Ownership | `capabilities` (JSON), `lease_id`, `assignment_id` |
|
||||
| Progress | `workflow_stage`, `last_completed_action`, `current_operation`, `pending_mutation` (JSON) |
|
||||
| Recovery | `evidence` (JSON), `blocker`, `next_valid_action`, `recovery_instructions` |
|
||||
| Bookkeeping | `checkpoint_schema_version`, `status`, `created_at`, `updated_at` |
|
||||
|
||||
### API
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `write_session_checkpoint` | Upsert the current-state row; redacts, and optionally enforces drain completeness |
|
||||
| `get_session_checkpoint` | Read one checkpoint by session + work unit |
|
||||
| `list_session_checkpoints` | List checkpoints by session or by work unit |
|
||||
| `reconcile_session_checkpoint` | Pure diagnosis of a stored checkpoint against live head/lease state |
|
||||
| `checkpoint_completeness` | Pure — the drain-required fields missing from a record |
|
||||
|
||||
### Hard rules
|
||||
|
||||
1. **Redaction at write.** Free-text columns are redaction-filtered and JSON columns are redacted recursively before storage, so a pasted token or credential URL cannot land in a checkpoint. Secrets are never stored (#660 AC4).
|
||||
2. **Reconcile, never blind-restore.** `reconcile_session_checkpoint` returns a diagnosis (`stale`, `head_mismatch`, `lease_mismatch`, `reconcile_action`) and the caller decides. A stored head that no longer matches live Git, or a lease that is gone or reassigned, marks the checkpoint stale (#660 AC3).
|
||||
3. **Unknown live state is not a mismatch.** A `None` live input means "not checked" and never flags staleness on its own.
|
||||
4. **Drain fails closed.** A write with `require_complete=True` refuses when any of `session_id`, `role`, `workflow_stage`, `next_valid_action`, `recovery_instructions` is missing or blank, so drain cannot complete on a checkpoint that could not resume.
|
||||
5. **No transcript storage.** Checkpoints hold resume state, not conversation history.
|
||||
|
||||
## Non-goals (intentionally deferred)
|
||||
|
||||
- Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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) — 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`
|
||||
|
||||
## 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 — 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 |
|
||||
|---|---|
|
||||
| `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**, contamination guard **#630**, and console restart UX **#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.
|
||||
@@ -0,0 +1,201 @@
|
||||
# ADR: MCP Control Plane Web Console architecture and information architecture
|
||||
|
||||
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
|
||||
- **Date:** 2026-07-22
|
||||
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
|
||||
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
|
||||
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
|
||||
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
|
||||
|
||||
## 1. Context
|
||||
|
||||
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
|
||||
|
||||
Twenty children (#632–#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
|
||||
|
||||
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
|
||||
|
||||
## 2. Decision summary (core)
|
||||
|
||||
| Layer | Owns | Must not |
|
||||
|-------|------|----------|
|
||||
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
|
||||
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
|
||||
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
|
||||
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
|
||||
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
|
||||
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
|
||||
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
|
||||
|
||||
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
|
||||
|
||||
## 3. Console surface today versus target
|
||||
|
||||
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
|
||||
|
||||
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
|
||||
|
||||
## 4. Authority boundaries
|
||||
|
||||
### 4.1 Gitea (durable record)
|
||||
|
||||
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
|
||||
|
||||
### 4.2 Control-plane DB (coordination)
|
||||
|
||||
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
|
||||
|
||||
### 4.3 MCP capability gates (authorization)
|
||||
|
||||
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
|
||||
|
||||
### 4.4 Filesystem and git (local state)
|
||||
|
||||
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
|
||||
|
||||
### 4.5 Providers (observe only)
|
||||
|
||||
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
|
||||
|
||||
## 5. Request flow and the redaction boundary
|
||||
|
||||
```text
|
||||
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
|
||||
│ ├──> control-plane DB
|
||||
│ ├──> filesystem / git
|
||||
│ └──> providers
|
||||
│
|
||||
[redaction boundary]
|
||||
│
|
||||
audit event
|
||||
```
|
||||
|
||||
| Stage | May hold credentials | Emits |
|
||||
|-------|----------------------|-------|
|
||||
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
|
||||
| Route layer → browser | **no** | redacted DTOs, HTML |
|
||||
|
||||
Two invariants govern the boundary and are non-negotiable for every child:
|
||||
|
||||
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
|
||||
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
|
||||
|
||||
## 6. API naming and versioning
|
||||
|
||||
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
|
||||
|
||||
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
|
||||
- Read endpoints are `GET` and side-effect free.
|
||||
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
|
||||
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
|
||||
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
|
||||
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
|
||||
|
||||
## 7. Page map
|
||||
|
||||
| Page | Purpose | Owning child | Evolves |
|
||||
|------|---------|--------------|---------|
|
||||
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
|
||||
| `/system` | System-health dashboard | #639 | new, backed by #634 |
|
||||
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
|
||||
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
|
||||
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
|
||||
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
|
||||
| `/timeline` | Workflow events and conversation timeline | #637 | new |
|
||||
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
|
||||
| `/gitea` | Issue and PR linkage console | #645 | new |
|
||||
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
|
||||
| `/notifications` | Human-attention routing | #648 | new |
|
||||
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
|
||||
| `/providers` | AI-provider connections and insights | #650 | new |
|
||||
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
|
||||
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
|
||||
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
|
||||
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
|
||||
|
||||
## 8. Component ownership for every epic child
|
||||
|
||||
Each #631 child maps to at least one architectural component defined above.
|
||||
|
||||
| Child | Capability area | Primary component | Phase |
|
||||
|-------|-----------------|-------------------|-------|
|
||||
| #632 | Architecture and information architecture | this ADR | 1 |
|
||||
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
|
||||
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
|
||||
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
|
||||
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
|
||||
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
|
||||
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
|
||||
| #639 | System-health dashboard | `/system` page over #634 | 1 |
|
||||
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
|
||||
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
|
||||
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
|
||||
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
|
||||
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
|
||||
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
|
||||
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
|
||||
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
|
||||
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
|
||||
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
|
||||
| #650 | AI-provider connections and operational insights | provider layer | 4 |
|
||||
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
|
||||
|
||||
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
|
||||
|
||||
## 9. Phase gates
|
||||
|
||||
| Phase | May ship | Entry condition |
|
||||
|-------|----------|-----------------|
|
||||
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
|
||||
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
|
||||
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
|
||||
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 1–3 |
|
||||
|
||||
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
|
||||
|
||||
## 10. Security and workflow safety
|
||||
|
||||
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
|
||||
- **Redact by default**, per §5.
|
||||
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
|
||||
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
|
||||
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
|
||||
|
||||
## 11. Forbidden paths
|
||||
|
||||
These are rejected designs, not preferences:
|
||||
|
||||
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
|
||||
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
|
||||
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
|
||||
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
|
||||
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
|
||||
6. **Recreating MVP scope.** Re-implementing a #426–#436 surface without an explicit evolve-or-extend statement is out of bounds.
|
||||
|
||||
## 12. Approval checklist (readable without chat history)
|
||||
|
||||
A controller can accept or reject this ADR against these six points alone:
|
||||
|
||||
1. Layers and their owners are defined (§2) and each authority is named (§4).
|
||||
2. The redaction boundary and the two invariants are stated (§5).
|
||||
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
|
||||
4. A page map exists and names an owning child for every page (§7).
|
||||
5. Every #631 child maps to at least one component and one phase (§8).
|
||||
6. Phase gates and forbidden paths are explicit (§9, §11).
|
||||
|
||||
## 13. Open questions and follow-ups
|
||||
|
||||
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
|
||||
|
||||
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
|
||||
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
|
||||
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
|
||||
- **Retention.** Audit-event retention duration is owned by #633.
|
||||
|
||||
## 14. Acceptance
|
||||
|
||||
Accepting this ADR means:
|
||||
|
||||
- Phase 1 children may proceed against the layers, page map, and API rules above.
|
||||
- Phase 2 children may not open a write path until #633 lands.
|
||||
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <pid>` 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()`.
|
||||
@@ -69,6 +69,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
- `gitea_authorize_review_correction`
|
||||
- `gitea_bootstrap_author_issue_worktree`
|
||||
- `gitea_capability_stop_terminal_report`
|
||||
- `gitea_capture_branches_worktree_snapshot`
|
||||
- `gitea_check_pr_eligibility`
|
||||
@@ -100,6 +101,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_get_profile`
|
||||
- `gitea_get_runtime_context`
|
||||
- `gitea_get_shell_health`
|
||||
- `gitea_heartbeat_issue_lock`
|
||||
- `gitea_heartbeat_reviewer_pr_lease`
|
||||
- `gitea_inspect_workflow_lease`
|
||||
- `gitea_issue_irrecoverable_provenance_authorization`
|
||||
@@ -120,6 +122,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_observability_list_projects`
|
||||
- `gitea_observability_reconcile_incident`
|
||||
- `gitea_post_heartbeat`
|
||||
- `gitea_publish_unpublished_issue_branch`
|
||||
- `gitea_quarantine_contaminated_review`
|
||||
- `gitea_reclaim_expired_workflow_lease`
|
||||
- `gitea_reconcile_already_landed_pr`
|
||||
@@ -134,6 +137,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`
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Model Usage, Token Cost, Latency, and Workflow Analytics (Phase 4)
|
||||
|
||||
- **Tracking Issue:** [#651](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/651)
|
||||
- **Parent Epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/651)
|
||||
- **Console Surface:** `/analytics`, `/api/v1/analytics`, `/api/v1/analytics/usage`
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Web Console Analytics module provides durable, aggregate visibility into **model usage, token cost, latency percentiles, and workflow-stage performance** across projects, worker roles, AI models, issues, and PRs.
|
||||
|
||||
### Non-Goals
|
||||
- No mandatory client-side telemetry that leaks prompts or secret keys.
|
||||
- No third-party payment provider or billing integration.
|
||||
- No automatic model routing changes without controller policy (#647).
|
||||
|
||||
---
|
||||
|
||||
## 2. Event Schema (`usage_events`)
|
||||
|
||||
Usage metrics are stored in the control-plane database under table `usage_events`.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `usage_id` | `INTEGER` | Primary key (autoincrement) |
|
||||
| `session_id` | `TEXT` | Optional active session identifier |
|
||||
| `remote` | `TEXT` | Known Gitea instance (`dadeschools` or `prgs`) |
|
||||
| `org` | `TEXT` | Repository owner / organization |
|
||||
| `repo` | `TEXT` | Repository name |
|
||||
| `project_id` | `TEXT` | Optional project identifier |
|
||||
| `role` | `TEXT` | Active worker role (`author`, `reviewer`, `merger`, `reconciler`, `controller`) |
|
||||
| `model` | `TEXT` | LLM model identifier (e.g. `gemini-3.6-flash`, `claude-3-5-sonnet`) |
|
||||
| `issue_number` | `INTEGER` | Correlated Gitea issue number (optional) |
|
||||
| `pr_number` | `INTEGER` | Correlated Gitea PR number (optional) |
|
||||
| `stage` | `TEXT` | Workflow stage (`preflight`, `implementation`, `review`, `merge`, `reconciliation`) |
|
||||
| `input_tokens` | `INTEGER` | Input token count (optional / nullable) |
|
||||
| `output_tokens` | `INTEGER` | Output token count (optional / nullable) |
|
||||
| `total_tokens` | `INTEGER` | Total token count (optional / nullable) |
|
||||
| `estimated_cost_usd` | `REAL` | Estimated USD cost (optional / nullable) |
|
||||
| `latency_ms` | `INTEGER` | Request latency in milliseconds (optional / nullable) |
|
||||
| `duration_ms` | `INTEGER` | Stage execution duration in milliseconds (optional / nullable) |
|
||||
| `status` | `TEXT` | Outcome status (`success`, `failure`, `timeout`) |
|
||||
| `metadata` | `TEXT` | Redacted metadata or summary string |
|
||||
| `created_at` | `TEXT` | ISO 8601 UTC timestamp |
|
||||
|
||||
---
|
||||
|
||||
## 3. Handling of Missing Data ("Unknown" vs. Zero Fabrication)
|
||||
|
||||
To ensure operational metrics accurately reflect evidence:
|
||||
- **Untracked or missing metrics are displayed as `Unknown`**, never zero-fabricated.
|
||||
- If an event omits `estimated_cost_usd`, `latency_ms`, or token counts, the aggregator marks those fields as missing (`None`) rather than defaulting to `0` or `$0.00`.
|
||||
- Summary tables and KPI cards explicitly indicate when data is unmeasured or partially reported.
|
||||
|
||||
---
|
||||
|
||||
## 4. Redaction & Security Rules
|
||||
|
||||
Per `#633` security policy:
|
||||
- Free-text fields (`metadata`, `prompt_summary`, `session_id`) are run through `console_redaction.redact_text` before persistence and output serialization.
|
||||
- Secret tokens, keychain commands, authorization headers, passwords, and JWTs are stripped automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Opt-in Instrumentation Guide
|
||||
|
||||
Applications, MCP servers, and background sessions can report usage metrics through either Python API or HTTP ingestion.
|
||||
|
||||
### Python Ingestion
|
||||
|
||||
```python
|
||||
from webui.analytics_loader import record_usage
|
||||
|
||||
record_usage(
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role="author",
|
||||
model="gemini-3.6-flash",
|
||||
issue_number=651,
|
||||
stage="implementation",
|
||||
input_tokens=1420,
|
||||
output_tokens=380,
|
||||
total_tokens=1800,
|
||||
estimated_cost_usd=0.00045,
|
||||
latency_ms=320,
|
||||
duration_ms=4500,
|
||||
status="success",
|
||||
metadata={"note": "Implementation of analytics module"},
|
||||
)
|
||||
```
|
||||
|
||||
### HTTP Ingestion API (authorized write)
|
||||
|
||||
`POST /api/v1/analytics/usage` is a **gated write**. It runs through
|
||||
`console_authz` action `record_analytics_usage` (operator+, Phase 2 execution).
|
||||
Unauthenticated or phase-inactive requests receive **403** and do not write.
|
||||
Prefer in-process `record_usage` for MCP / session instrumentation.
|
||||
|
||||
```http
|
||||
POST /api/v1/analytics/usage HTTP/1.1
|
||||
Content-Type: application/json
|
||||
# Requires authenticated principal with record_analytics_usage execution enabled
|
||||
|
||||
{
|
||||
"remote": "dadeschools",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"role": "author",
|
||||
"model": "gemini-3.6-flash",
|
||||
"issue_number": 651,
|
||||
"stage": "implementation",
|
||||
"input_tokens": 1420,
|
||||
"output_tokens": 380,
|
||||
"total_tokens": 1800,
|
||||
"estimated_cost_usd": 0.00045,
|
||||
"latency_ms": 320,
|
||||
"duration_ms": 4500,
|
||||
"status": "success",
|
||||
"metadata": "Analytics schema landed"
|
||||
}
|
||||
```
|
||||
|
||||
### Retention
|
||||
|
||||
`usage_events` is retained with hard caps applied on every write:
|
||||
|
||||
| Limit | Default |
|
||||
|---|---|
|
||||
| Max rows | 10,000 (`ControlPlaneDB.USAGE_EVENTS_MAX_ROWS`) |
|
||||
| Max age | 90 days (`ControlPlaneDB.USAGE_EVENTS_RETENTION_DAYS`) |
|
||||
|
||||
Older rows (by `created_at`) and excess oldest rows (by `usage_id`) are deleted
|
||||
after each insert so unbounded growth / DoS-by-volume cannot fill the DB.
|
||||
|
||||
---
|
||||
|
||||
## 6. Querying Analytics API
|
||||
|
||||
```http
|
||||
GET /api/v1/analytics?role=author&stage=implementation HTTP/1.1
|
||||
```
|
||||
|
||||
Returns `AnalyticsSnapshot` JSON containing aggregations (`by_model`, `by_stage`, `by_role`, `by_work_item`, `by_project`) and latency percentiles (`p50`, `p90`, `p95`, `p99`).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Post-restart MCP reconciliation (#662)
|
||||
|
||||
After an MCP process restart, sessions, leases, capabilities, worktrees, and
|
||||
interrupted mutations must be reconciled before operators claim a clean runtime.
|
||||
This document describes the #662 completion-proof path.
|
||||
|
||||
## Components
|
||||
|
||||
| Piece | Where | Responsibility |
|
||||
|-------|-------|----------------|
|
||||
| `post_restart_reconcile.reconcile_after_restart` | `post_restart_reconcile.py` | Pure classification: inventory → completion proof DTO. No I/O. |
|
||||
| `RestartCompletionProof` | `post_restart_reconcile.py` | Machine-readable proof (`.as_dict()` is JSON-serializable). |
|
||||
| `gitea_reconcile_after_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 control-plane DB + master-parity, classifies, returns the proof. Read-only. |
|
||||
| Boot hook | `gitea_assess_master_parity` | First post-restart parity probe also runs reconcile once (log-only by default). |
|
||||
|
||||
## Dimensions
|
||||
|
||||
The assessor classifies:
|
||||
|
||||
- **service_health** — process healthy / parity mutation-safe
|
||||
- **clients** — connected client descriptors (optional inventory)
|
||||
- **sessions** — active session rows with dead owner pids are unresolved
|
||||
- **checkpoints** — soft-depends on #660; skipped with reason when schema absent
|
||||
- **leases** — live control-plane leases after restart
|
||||
- **capabilities** — master-parity / stale-runtime (#610)
|
||||
- **worktrees** — lease-bound paths missing on disk
|
||||
- **interrupted_mutations** — mutating lease phases or explicit pending inventory; **never auto-resumed**
|
||||
- **duplicates** — multiple live claims on the same work item
|
||||
- **queue** — allocator resume safety
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Env / arg | Behavior |
|
||||
|------|-----------|----------|
|
||||
| `log_only` (default) | unset or `GITEA_POST_RESTART_RECONCILE_MODE=log_only` | Proof only; `mutation_hold=false` |
|
||||
| `enforce` | `GITEA_POST_RESTART_RECONCILE_MODE=enforce` or `mode=enforce` | Sets `mutation_hold=true` when overall status is degraded/failed or interrupted mutations remain |
|
||||
|
||||
## Follow-up issues
|
||||
|
||||
Unresolved dimensions produce `proposed_follow_ups` entries suitable for durable
|
||||
Gitea issues. The MCP tool **does not create** those issues in v1 (rollout is
|
||||
log-only first). Controllers may file them from the proof payload.
|
||||
|
||||
## Links
|
||||
|
||||
- Umbrella: #655
|
||||
- Vision: #652 · Roadmap: #653
|
||||
- Checkpoint schema: #660 (soft dependency)
|
||||
- Drain proof: #661 (soft)
|
||||
- This issue: #662
|
||||
|
||||
## Non-goals
|
||||
|
||||
- HA multi-instance failover
|
||||
- Automatic silent mutation replay
|
||||
- Implementing the #660 checkpoint schema itself
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Sanctioned restart and graceful reload controls (#642)
|
||||
|
||||
Sessions used to recover MCP connectivity by killing the host daemon
|
||||
(`pkill -f mcp_server.py`, #630). That is forbidden and stays forbidden: it
|
||||
kills every namespace on the host, contaminates whichever session survives, and
|
||||
leaves no audit trail. This document describes the sanctioned replacement,
|
||||
implemented in `webui/sanctioned_restart.py`.
|
||||
|
||||
## What the console will and will not do
|
||||
|
||||
The console **never** restarts anything. It authorizes an intent, records it,
|
||||
and hands off to a host supervisor. There is no code path in which the console
|
||||
sends a signal, spawns a process, or renders a kill command — a regression test
|
||||
asserts the module contains no `subprocess`, `signal`, `os.kill`, `os.system`,
|
||||
or `popen` reference, and that no returned payload contains a kill command.
|
||||
|
||||
## Operations
|
||||
|
||||
| Mode | Action | Minimum role | Behaviour |
|
||||
|------|--------|--------------|-----------|
|
||||
| `reload` | `system.reload_namespace` | controller | Host supervisor reloads the namespace in place, draining in-flight requests. |
|
||||
| `restart` | `system.restart_namespace` | admin | Host supervisor restarts the namespace. In-flight requests are lost. |
|
||||
|
||||
Scope is always exactly one namespace. A fleet-wide restart is an explicit
|
||||
non-goal: `all`, `*`, `fleet`, and an empty scope are refused with
|
||||
`fleet_scope_not_permitted`, because that is precisely the blast radius the
|
||||
forbidden kill already had. An unrecognised namespace is refused rather than
|
||||
passed through to the host.
|
||||
|
||||
## The gate sequence
|
||||
|
||||
`assess_restart_request()` applies every gate in order and reports the first
|
||||
failure with a stable reason code:
|
||||
|
||||
| Order | Gate | Reason code on failure |
|
||||
|-------|------|------------------------|
|
||||
| 1 | Mode is `restart` or `reload` | `unknown_mode` |
|
||||
| 2 | Scope is a single known namespace | `fleet_scope_not_permitted`, `unknown_namespace` |
|
||||
| 3 | Principal holds the required console role | `unauthorized` |
|
||||
| 4 | Confirmation phrase supplied | `confirmation_required` |
|
||||
| 5 | Confirmation names this namespace and mode | `confirmation_mismatch` |
|
||||
| 6 | Out-of-band operator authorization present | `operator_authorization_missing` |
|
||||
| 7 | Runtime is not contaminated | `contaminated_runtime` |
|
||||
| 8 | Host restart hook configured | `restart_hook_not_configured` |
|
||||
|
||||
Passing every gate yields `host_action_required`, never "restarted".
|
||||
|
||||
### Confirmation binds the namespace
|
||||
|
||||
The required phrase is `"<mode> <namespace>"` — for example
|
||||
`restart gitea-author`. Binding the namespace into the phrase is the point: a
|
||||
confirmation typed for one namespace cannot be replayed against another.
|
||||
|
||||
### Operator authorization is not self-assertable
|
||||
|
||||
Host daemon maintenance is authorized out of band through
|
||||
`GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION`, read from the process
|
||||
environment and nowhere else (#630; #710 finding F1). A worker session cannot
|
||||
set an environment variable for an already-running daemon, so this cannot be
|
||||
faked the way a tool argument could.
|
||||
|
||||
### The host hook
|
||||
|
||||
`GITEA_SANCTIONED_RESTART_HOOK` holds an opaque reference the *host* resolves —
|
||||
a supervisor label such as a launchd job name, never a command line. With no
|
||||
hook configured the request is refused; the console does not fall back to a
|
||||
process kill. The value is read server-side and never rendered to a client.
|
||||
|
||||
## Manual kill remains contamination
|
||||
|
||||
`classify_restart_command()` classifies an operator-proposed recovery command.
|
||||
A manual `pkill`/`kill`/`killall` of the MCP daemon is contamination, not a
|
||||
restart: it returns `clean_claim_allowed: false` and builds a durable
|
||||
contamination marker (redacted command only, never secrets) naming
|
||||
`system.restart_namespace` as the sanctioned alternative.
|
||||
|
||||
A live, uncleared contamination marker also blocks a restart. This is stricter
|
||||
than #630's task-scoped gate, which deliberately lets a contaminated worker keep
|
||||
commenting and handing off: restarting a contaminated runtime would launder the
|
||||
contamination rather than resolve it. Clear the marker through the reconciler
|
||||
path first.
|
||||
|
||||
## Post-restart health verification
|
||||
|
||||
After the host supervisor acts, `verify_post_restart_health()` decides whether
|
||||
the session may claim to be clean:
|
||||
|
||||
| Status | Meaning | Clean claim |
|
||||
|--------|---------|-------------|
|
||||
| `clean` | Required tool callable, proven through the live client namespace | Allowed |
|
||||
| `unproven` | Reported healthy without live client-namespace evidence | Refused |
|
||||
| `unhealthy` | Probe failed | Refused |
|
||||
|
||||
Only `probe_source=client_namespace` evidence clears a session. Static tool
|
||||
registration is not proof, and neither is an offline subprocess probe — an IDE
|
||||
client can hold a registered tool list while live calls fail with
|
||||
`client is closing: EOF` (see
|
||||
[`mcp-namespace-health.md`](mcp-namespace-health.md)).
|
||||
|
||||
## Audit
|
||||
|
||||
Every attempt — allowed or denied — is recorded through
|
||||
`webui.console_audit` with actor, target namespace, mode, result, and reason
|
||||
code, and is redacted before it is persisted. `system.restart_namespace` is
|
||||
break-glass, so its records are retained for 730 days. Records carry
|
||||
`process_kill_executed: false`, which is a fact about the code path rather than
|
||||
a claim: no such path exists.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `GITEA_SANCTIONED_RESTART_HOOK` | Host supervisor reference; absent means restart is refused. |
|
||||
| `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` | Out-of-band operator authorization reference. |
|
||||
| `WEBUI_AUDIT_LOG` | Console audit sink; absent means records are built but not persisted. |
|
||||
|
||||
## Non-goals
|
||||
|
||||
* No unrestricted `kill` from the UI, in any role, in any phase.
|
||||
* No fleet-wide restart.
|
||||
* No silent auto-restart loop: every attempt is confirmed and audited.
|
||||
* This does not implement the Phase 1 health API (#634).
|
||||
@@ -0,0 +1,305 @@
|
||||
# Web console authorization, RBAC, redaction, and audit model (#633)
|
||||
|
||||
**Phase 1. Read-only. This document defines the model that future console
|
||||
writes must pass through; it enables none of them.**
|
||||
|
||||
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
|
||||
documents internal-only serving and states plainly that MVP authentication is
|
||||
*none* — protection comes from network placement. That is adequate while every
|
||||
route is a GET, and inadequate the moment a gated write ships. This document
|
||||
and the three modules it describes land **before** any write exists, so no
|
||||
Phase 2 action can be added without an authority to check it against.
|
||||
|
||||
| Concern | Module |
|
||||
|---------|--------|
|
||||
| Identity, roles, authorization decision | `webui/console_authz.py` |
|
||||
| Secret redaction for every surface | `webui/console_redaction.py` |
|
||||
| Audit event schema, retention, sink | `webui/console_audit.py` |
|
||||
| Machine-readable publication | `GET /api/console/security-model` |
|
||||
|
||||
Two invariants hold everywhere and are non-negotiable for every child of #631:
|
||||
|
||||
1. **No secrets reach the browser.** Credentials are resolved server-side and
|
||||
redacted before any payload, page, log line, or audit record leaves.
|
||||
2. **No ungated mutations.** Authorization is necessary but never sufficient;
|
||||
execution stays disabled until the Phase 2 framework ships.
|
||||
|
||||
## Identity sources
|
||||
|
||||
The console performs *authorization*. Authentication is delegated, because a
|
||||
console that mints its own sessions is a credential store, and this one must
|
||||
not be.
|
||||
|
||||
| Source | Mode value | Authenticated | Shared host | Phase |
|
||||
|--------|-----------|---------------|-------------|-------|
|
||||
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
|
||||
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
|
||||
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
|
||||
|
||||
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
|
||||
rather than erroring open.
|
||||
|
||||
**Access-proxy mode** reads the subject from the
|
||||
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
|
||||
an equivalent org portal that terminates authentication in front of the
|
||||
console. If the header is absent the request did not traverse the proxy, so the
|
||||
principal degrades to anonymous — it is never trusted by default.
|
||||
|
||||
The **role is always server-side configuration**, never a client assertion. It
|
||||
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
|
||||
|
||||
```json
|
||||
{"[email protected]": "operator", "[email protected]": "controller"}
|
||||
```
|
||||
|
||||
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
|
||||
everyone gets `viewer` — a parse failure loses authority rather than granting
|
||||
it.
|
||||
|
||||
Full SSO is explicitly a non-goal of this issue.
|
||||
|
||||
## Role matrix
|
||||
|
||||
Four roles, ordered least to most authority. Each role inherits every lower
|
||||
role's actions; the table states the *minimum* rank required.
|
||||
|
||||
| Role | Authority |
|
||||
|------|-----------|
|
||||
| `viewer` | Read every console view. No write, ever, in any phase. |
|
||||
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
|
||||
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
|
||||
| `admin` | Controller, plus destructive and policy-editing actions. |
|
||||
|
||||
`viewer` holds the empty write set by construction, and a test asserts it stays
|
||||
empty.
|
||||
|
||||
## Privileged actions
|
||||
|
||||
Every console action maps to a `task_key` in `task_capability_map.py`, the same
|
||||
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
|
||||
use. The console therefore cannot invent an authority the MCP layer does not
|
||||
already define, and a regression test asserts each mapping matches.
|
||||
|
||||
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|
||||
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
|
||||
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
|
||||
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
|
||||
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
|
||||
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
|
||||
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
|
||||
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
|
||||
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
|
||||
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
||||
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
||||
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
||||
|
||||
**Dual control** means the acting principal may not be the sole authority: a
|
||||
second distinct principal must confirm. **Break-glass** means the action is
|
||||
expected to be unavailable in normal operation and its use is retained for two
|
||||
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
|
||||
records the requirement on every decision so the framework cannot ship without
|
||||
honouring it.
|
||||
|
||||
`delete_branch` is admin-only rather than controller because it is the one
|
||||
irreversible action in the set.
|
||||
|
||||
`system.restart_namespace` is admin-only for the same reason: restarting a
|
||||
namespace drops every in-flight request on it. `system.reload_namespace` drains
|
||||
first, so it is privileged but not destructive. Neither action is ever executed
|
||||
by the console — both hand off to a host supervisor, and neither exposes a raw
|
||||
process kill. See
|
||||
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
||||
|
||||
### Authorization decision
|
||||
|
||||
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||
record and **denies by default**. The deny reasons are closed and enumerated:
|
||||
|
||||
| Reason code | Meaning |
|
||||
|-------------|---------|
|
||||
| `unknown_action` | No such console action is registered. |
|
||||
| `unauthenticated` | The principal is anonymous. |
|
||||
| `unknown_role` | The role is not in the matrix. |
|
||||
| `insufficient_role` | The role ranks below the action's minimum. |
|
||||
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||
|
||||
There is no implicit allow branch. Even the allow result reports
|
||||
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
||||
read an allow as permission to mutate.
|
||||
|
||||
## Secret redaction
|
||||
|
||||
One pass applies to **API payloads, rendered HTML, server logs, and audit
|
||||
records** — the four surfaces where a credential could escape.
|
||||
|
||||
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
|
||||
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
|
||||
The console layer then applies its own patterns:
|
||||
|
||||
Each rule below matches an *assignment form*: the named key, followed by `=` or
|
||||
`:`, followed by the value. The keys are listed bare rather than spelled out as
|
||||
complete assignments, because this document is itself scanned by
|
||||
`scan_for_secrets` — writing the examples in full assignment form would make the
|
||||
documentation trip the very detectors it documents.
|
||||
|
||||
| Rule | Catches (as an assignment) |
|
||||
|------|----------------------------|
|
||||
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
|
||||
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
|
||||
| `keychain_reference` | `keychain:` entry references |
|
||||
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
|
||||
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
|
||||
| `json_web_token` | Three-segment `eyJ...` JWTs |
|
||||
| `bearer_credential` | `Bearer` / `Basic` credentials |
|
||||
|
||||
Assignments keep the key and replace only the value, so an operator can still
|
||||
see *what* was removed. Two behaviours are deliberate:
|
||||
|
||||
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
|
||||
outright rather than being emitted raw. Redaction never raises.
|
||||
- **Redact before persist.** `console_audit.build_event` redacts before
|
||||
serialization, and `write_event` re-scans and **drops** any record that still
|
||||
trips a detector. An unredacted record is never durable.
|
||||
|
||||
`scan_for_secrets` is the assertion helper: it returns the detector names still
|
||||
matching a payload, and already-redacted hits are not findings. Tests use it to
|
||||
prove the published policy, the security-model endpoint, and this document
|
||||
itself carry no secret material.
|
||||
|
||||
## Audit event schema
|
||||
|
||||
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
|
||||
performed which tool call. It has no console actor, no identity source, no
|
||||
correlation identifier, and no retention class, and an authorization **denial**
|
||||
is not a mutation, so it would never appear there at all. The console record is
|
||||
additive, not a replacement: a Phase 2 action emits both, joined on
|
||||
`correlation.request_id`.
|
||||
|
||||
Required fields, all asserted by tests so an edit cannot quietly drop one:
|
||||
|
||||
| Field | Content |
|
||||
|-------|---------|
|
||||
| `schema_version` | Currently `1`. |
|
||||
| `event_id` | Unique per record. |
|
||||
| `timestamp` | Timezone-aware ISO-8601, UTC. |
|
||||
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
|
||||
| `action` | Console action id. |
|
||||
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
|
||||
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
|
||||
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
|
||||
| `reason_code` | The authorization reason code above. |
|
||||
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
|
||||
| `retention` | `class`, `days`, `expires_at`. |
|
||||
| `redacted` | Always `true`; records are redacted at build time. |
|
||||
|
||||
An unrecognised `result` degrades to `failed` rather than being stored
|
||||
verbatim.
|
||||
|
||||
The sink is an append-only JSON Lines file named by
|
||||
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
|
||||
events are still built — so callers and tests exercise the schema — but nothing
|
||||
is written. Auditing never raises; a failed write returns `False` rather than
|
||||
breaking the request it describes.
|
||||
|
||||
## Retention
|
||||
|
||||
| Class | Applies to | Default |
|
||||
|-------|-----------|---------|
|
||||
| `standard` | Routine gated writes | 90 days |
|
||||
| `privileged` | `review_pr`, `close_pr`, `system.reload_namespace`, and any unclassifiable action | 365 days |
|
||||
| `break_glass` | `merge_pr`, `delete_branch`, `system.restart_namespace` | 730 days |
|
||||
|
||||
Each record carries its own class, day count, and computed `expires_at`, so
|
||||
retention is auditable per record rather than inferred from file age. An
|
||||
**unknown action is retained as privileged, not standard** — for a safety
|
||||
control the conservative direction is to keep the record longer.
|
||||
|
||||
Nothing in this module updates or deletes. Expiry is enforced by an
|
||||
operator-run policy against `expires_at`, never by the console silently
|
||||
rewriting its own history.
|
||||
|
||||
## Phase 2 integration
|
||||
|
||||
Phase 2 opens gated writes. It must reuse this model rather than introduce a
|
||||
second one. The integration points are already wired and observable:
|
||||
|
||||
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
|
||||
to the existing preview payload and records a `previewed` audit event.
|
||||
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
|
||||
records a `denied` event. The terminal outcome is unchanged — the MVP
|
||||
registry in `webui/gated_actions.py` still fails closed for every action — so
|
||||
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
|
||||
instead of adding a parallel check.
|
||||
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||
policy, and audit policy as JSON for operators and tests.
|
||||
|
||||
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
||||
confirmation and dual-control flow the matrix already declares, emit a
|
||||
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
||||
keep `viewer` unable to reach any of it. Turning on execution without the
|
||||
confirmation flow contradicts a declared requirement and is a review failure,
|
||||
not a shortcut.
|
||||
|
||||
## Local-dev mode
|
||||
|
||||
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
|
||||
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
|
||||
|
||||
**INSECURE — this mode is for loopback development only.** The subject and role
|
||||
are *asserted by the developer running the process and verified by nothing*.
|
||||
Anyone able to set an environment variable on the host is an `admin`, and
|
||||
anyone able to reach the port inherits that principal. It provides no
|
||||
authentication whatsoever; it exists so Phase 2 authorization paths can be
|
||||
exercised without standing up a proxy.
|
||||
|
||||
Never enable local-dev mode on a non-loopback bind. Combining it with
|
||||
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
|
||||
unauthenticated admin console.
|
||||
|
||||
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
|
||||
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
|
||||
|
||||
### Probe authentication
|
||||
|
||||
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
|
||||
authenticated principal. It is **opt-in**: the default is off so the MVP
|
||||
`/health` contract is unchanged.
|
||||
|
||||
**This flag is declarative in Phase 1 and enforces nothing today.**
|
||||
`console_authz.probe_auth_required()` reports the operator's intent, and no
|
||||
route consults it — setting the variable does not currently change the
|
||||
behaviour of `/health` or any other endpoint. It is published here so the Phase
|
||||
2 action framework has a declared policy to honour rather than inventing a
|
||||
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
|
||||
already declared. A regression test pins this "declared, not enforced" status,
|
||||
so wiring it later is a deliberate change rather than a silent one.
|
||||
|
||||
Until Phase 2 wires it, probe protection rests on network placement alone, as
|
||||
[`webui-deployment.md`](webui-deployment.md) (#435) states.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
|
||||
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
|
||||
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
|
||||
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||
|
||||
All are read server-side only. None is ever rendered into a page or returned by
|
||||
an API.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No full SSO product; authentication stays delegated to the proxy.
|
||||
- No browser-initiated merges or approvals in any phase covered here.
|
||||
- No tokens in the frontend, in browser storage, or in committed config.
|
||||
@@ -7,7 +7,10 @@ only.
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Authentication:** none in MVP — protection comes from network placement.
|
||||
The authorization, RBAC, redaction, and audit model that future gated writes
|
||||
must pass through is defined in
|
||||
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
@@ -52,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
|
||||
|
||||
+301
-3
@@ -37,17 +37,31 @@ Optional environment variables:
|
||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
|
||||
See
|
||||
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
for the console architecture: layer and authority boundaries, the redaction
|
||||
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
|
||||
that govern when a write path may open (#632, epic #631).
|
||||
|
||||
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
|
||||
versioned project registry contract: registry schema versions 1 and 2, project
|
||||
status, onboarding checklist state, and the fail-closed error payloads (#635).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Home / operator overview |
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/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 (#427) |
|
||||
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/api/v1/projects` | Versioned JSON registry export (#635) |
|
||||
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
|
||||
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
@@ -61,11 +75,95 @@ Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
| `/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
|
||||
local validator preview only (no Gitea mutations, no server-side storage).
|
||||
|
||||
## System health API (#634)
|
||||
|
||||
`GET /api/v1/system/health` is the structured, read-only health surface for
|
||||
automated readiness checks. It is the first console API under the `/api/v1`
|
||||
prefix; the unversioned MVP exports remain as compatibility aliases.
|
||||
|
||||
`/health` is unchanged for existing consumers — every MVP key is still present
|
||||
— and now also carries `started_at`, `uptime_seconds`, and a
|
||||
`system_health_api` pointer. It stays deliberately cheap and runs no dependency
|
||||
probe, because answering readiness costs real work.
|
||||
|
||||
**Status codes.** `200` when ready, `503` when a required dependency failed or
|
||||
was never probed. Automation can branch on the code without parsing the body.
|
||||
|
||||
**Query flags.** The Gitea check is a network call, so it is opt-in:
|
||||
`GET /api/v1/system/health?deep=1` runs it and caches the result for
|
||||
`WEBUI_HEALTH_PROBE_TTL_SECONDS` (default 15s) so dashboard polling does not
|
||||
amplify into remote load. Without the flag that probe reports `skipped`.
|
||||
|
||||
**Dependencies.** `control_plane_db` and `repository` are required and drive
|
||||
readiness. `gitea` is optional: when it fails the overall `status` degrades but
|
||||
`readiness.ready` stays true, because local inventory is still serveable. Each
|
||||
entry carries `status`, `detail`, `required`, and `latency_ms`.
|
||||
|
||||
Two honesty rules are worth knowing before reading the payload:
|
||||
|
||||
* `stale_runtime.mutation_safe` is true only when the runtime, checkout, and
|
||||
remote-tracking commits are all known and equal. An unfetched remote is
|
||||
reported as indeterminate, never as safe.
|
||||
* `mcp_namespaces` entries are always `unproven`. A web process runs outside
|
||||
the IDE-managed MCP client and cannot invoke a namespace tool, so per #543
|
||||
only a `client_namespace` probe can prove that path.
|
||||
|
||||
Sample response (abridged, healthy):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "mcp-control-plane-webui",
|
||||
"mode": "read-only",
|
||||
"api": "/api/v1/system/health",
|
||||
"timestamp": "2026-07-22T11:04:18.512034+00:00",
|
||||
"readiness": { "ready": true, "complete": true, "reasons": [] },
|
||||
"version": {
|
||||
"git_sha": "620ed6e9a9550b8da2ceb82d9ab8744e8920490f",
|
||||
"git_describe": "v1.1.0-898-g620ed6e",
|
||||
"control_plane_schema_version": 4,
|
||||
"python_version": "3.14.5",
|
||||
"known": true
|
||||
},
|
||||
"process": { "started_at": "2026-07-22T10:58:02.114+00:00", "uptime_seconds": 376.4 },
|
||||
"deep_probes_requested": false,
|
||||
"dependencies": [
|
||||
{
|
||||
"name": "control_plane_db",
|
||||
"kind": "sqlite",
|
||||
"status": "ok",
|
||||
"detail": "schema v4 readable",
|
||||
"required": true,
|
||||
"healthy": true,
|
||||
"latency_ms": 1.482,
|
||||
"metadata": { "schema_version": 4, "active_leases": 3 }
|
||||
},
|
||||
{ "name": "repository", "kind": "git", "status": "ok", "required": true, "healthy": true },
|
||||
{ "name": "gitea", "kind": "http", "status": "skipped", "required": false, "healthy": false }
|
||||
],
|
||||
"mcp_namespaces": [
|
||||
{ "namespace": "gitea-author", "required_tool": "gitea_whoami", "status": "unproven" }
|
||||
],
|
||||
"stale_runtime": { "stale": false, "determinable": true, "mutation_safe": true, "reasons": [] },
|
||||
"probe_errors": []
|
||||
}
|
||||
```
|
||||
|
||||
No restart, reload, or process-kill control is exposed here: those are Phase 2
|
||||
at the earliest, and #630 forbids process-kill recovery outright. Every probe
|
||||
opens its subject read-only — the control-plane database is opened through a
|
||||
`mode=ro` URI so a health check can never create or migrate a schema.
|
||||
|
||||
## Report audit (#431)
|
||||
|
||||
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||
@@ -141,6 +239,57 @@ 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`.
|
||||
|
||||
## 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**
|
||||
@@ -200,6 +349,155 @@ 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.
|
||||
|
||||
## Inventory API (#636)
|
||||
|
||||
`GET /api/v1/inventory` returns one versioned, read-only snapshot that unifies
|
||||
what the lease (#433), worktree (#432), and runtime (#430) MVP views each show
|
||||
separately, so traffic-control and recovery consumers read the same source.
|
||||
`GET /api/v1/inventory/{section}` returns a single section under the identical
|
||||
schema (`sessions`, `leases`, `locks`, `worktrees`, `namespaces`); an unknown
|
||||
section is a `404` with `error: unknown_section`. Both routes are `GET`-only.
|
||||
|
||||
Each section carries its own `status` (`ok` / `degraded` / `unavailable`), a
|
||||
`reason` when not `ok`, and a `scan_ms`. A subsystem that cannot be read
|
||||
degrades to a reasoned section; it never raises and never emits an empty list
|
||||
that would read as "nothing is there".
|
||||
|
||||
### Field authority
|
||||
|
||||
Every section names where its rows came from; authorities are never blended.
|
||||
|
||||
| Section | Authority | Source |
|
||||
|---|---|---|
|
||||
| `sessions` | `control_plane_db` | #613 control-plane DB (`mode=ro`), authoritative for exclusive ownership (#600/#601) |
|
||||
| `leases` | `control_plane_db` | #613 control-plane DB; degrades if the `work_items` table is absent |
|
||||
| `locks` | `filesystem` | durable per-issue lock files (`issue_lock_store`) |
|
||||
| `worktrees` | `filesystem` | registered git worktrees via the #432 hygiene scanner |
|
||||
| `namespaces` | `filesystem` | the active profile serving this web process (others are not enumerable) |
|
||||
|
||||
The payload restates this map under `field_authority` for machine consumers.
|
||||
|
||||
### Ownership safety
|
||||
|
||||
`ownership_authority_complete` is true only when every ownership-bearing section
|
||||
(`sessions`, `leases`, `locks`) read cleanly. While it is false, nothing is
|
||||
reported as unowned and no collision is asserted from a degraded source —
|
||||
absence of evidence is reported as absence of evidence, never as free work.
|
||||
|
||||
`collisions` surfaces detectable conflicts, each with a `kind` and `severity`:
|
||||
`lock-without-worktree`, `duplicate-live-lock`, `live-lock-dead-owner` (unexpired
|
||||
lease, dead pid — a #753 recovery candidate that would read as live to a naive
|
||||
timestamp check), `stale-lock-dead-owner`, `expired-lock-live-owner` (the
|
||||
#635/#760 daemon-pid deadlock), `concurrent-active-lease`, `active-lease-past-expiry`,
|
||||
and `orphan-lease`. Collisions are emitted only from sections that read cleanly.
|
||||
|
||||
The control-plane DB is opened through a `mode=ro` URI so a read never creates
|
||||
or migrates it; paths are collapsed against `$HOME`, URLs lose userinfo and
|
||||
query strings, and credential-shaped values are redacted at the boundary. Lease
|
||||
steal/release and worktree deletion are Phase 2+ and have no representation here.
|
||||
|
||||
## 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:<event_id>`,
|
||||
`cth:<kind>:<number>:<comment_id>`), 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.
|
||||
|
||||
### 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, 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
|
||||
pytest tests/test_webui_timeline.py -q
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Project registry API (#635)
|
||||
|
||||
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
gives the project registry a versioned, read-only API. This document is the
|
||||
field-by-field contract for that API and for the registry file behind it.
|
||||
|
||||
Everything here is **read-only**. The console never writes the registry; an
|
||||
operator edits the JSON file, and an invalid file fails closed rather than
|
||||
rendering a partial inventory.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
|
||||
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
|
||||
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
|
||||
| `/projects` | GET | HTML list — status and onboarding progress per project |
|
||||
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
|
||||
|
||||
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
|
||||
and only after this document and `webui-local-dev.md` record the swap. The alias
|
||||
returns the same payload as `/api/v1/projects`, including the legacy `version`
|
||||
and `source_path` keys #427 consumers already read.
|
||||
|
||||
The HTML views render from the same DTO the JSON routes serialize
|
||||
(`project_to_dict`), so the console and the API cannot disagree about a
|
||||
project's status or onboarding progress.
|
||||
|
||||
## Registry file
|
||||
|
||||
Default location: `webui/data/projects.registry.json`. Override with the
|
||||
`WEBUI_PROJECT_REGISTRY` environment variable.
|
||||
|
||||
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
|
||||
file loads unchanged and is normalized with the documented defaults, so an
|
||||
existing operator registry keeps working without edits.
|
||||
|
||||
### Root
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `version` | int | yes | `1` or `2`. Anything else fails closed |
|
||||
| `projects` | array | yes | Must be non-empty |
|
||||
|
||||
### Project
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable registry id used in URLs |
|
||||
| `repo_name` | string | yes | — | Gitea repository name |
|
||||
| `gitea_owner` | string | yes | — | Owning org or user |
|
||||
| `remote_host` | string | yes | — | Instance base URL, no credentials |
|
||||
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
|
||||
| `default_branch` | string | yes | — | Stable branch name |
|
||||
| `local_checkout_path` | string | yes | — | Control checkout path |
|
||||
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
|
||||
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
|
||||
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
|
||||
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
|
||||
| `onboarding_checklist` | array | no | `[]` | See below |
|
||||
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
|
||||
|
||||
### Onboarding step
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable step id |
|
||||
| `title` | string | yes | — | Short operator-facing label |
|
||||
| `description` | string | yes | — | Self-contained; assumes no chat history |
|
||||
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
|
||||
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
|
||||
|
||||
### Last-seen health
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
|
||||
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
|
||||
| `detail` | string | no | Short redacted note |
|
||||
|
||||
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
|
||||
health checks. Endpoints, tokens, and keychain identifiers must never appear
|
||||
here.
|
||||
|
||||
## Response shape
|
||||
|
||||
`GET /api/v1/projects`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": "v1",
|
||||
"schema_version": 2,
|
||||
"version": 2,
|
||||
"source_path": "/path/to/webui/data/projects.registry.json",
|
||||
"source": {
|
||||
"kind": "file",
|
||||
"path": "/path/to/webui/data/projects.registry.json",
|
||||
"inventory_complete": true
|
||||
},
|
||||
"project_count": 1,
|
||||
"projects": [
|
||||
{
|
||||
"id": "example",
|
||||
"repo_name": "Example",
|
||||
"gitea_owner": "Org",
|
||||
"repo_full_name": "Org/Example",
|
||||
"remote_host": "https://gitea.example.invalid",
|
||||
"remote_name": "example-remote",
|
||||
"default_branch": "main",
|
||||
"local_checkout_path": ".",
|
||||
"status": "active",
|
||||
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
|
||||
"workflow_paths": {"skill": "skills/..."},
|
||||
"schema_paths": {},
|
||||
"onboarding_checklist": [
|
||||
{
|
||||
"id": "profiles",
|
||||
"title": "Configure execution profiles",
|
||||
"description": "...",
|
||||
"state": "complete",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"onboarding_summary": {
|
||||
"total": 1,
|
||||
"complete": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"not_applicable": 0,
|
||||
"required_outstanding": 0,
|
||||
"onboarding_complete": true
|
||||
},
|
||||
"last_seen_health": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
|
||||
`source`, and a single `project` object with the same fields.
|
||||
|
||||
The `source` block satisfies the ADR section 6 provenance rule: every payload
|
||||
states where the data came from and whether the inventory is complete. A
|
||||
file-backed registry is always complete — there is no pagination to truncate it.
|
||||
|
||||
`onboarding_summary` is derived, never stored. `required_outstanding` counts
|
||||
steps that are `required` **and** in state `pending` or `blocked`;
|
||||
`onboarding_complete` is true when that count is zero.
|
||||
|
||||
## Fail-closed errors
|
||||
|
||||
Validation failures raise `RegistryError`, which routes render instead of a
|
||||
traceback.
|
||||
|
||||
`404` — unknown project id on `/api/v1/projects/{project_id}`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "project_not_found",
|
||||
"project_id": "not-registered",
|
||||
"known_project_ids": ["example"],
|
||||
"remediation": "Request one of the known project ids, or add the project ...",
|
||||
"source": {"kind": "file", "path": "...", "inventory_complete": true}
|
||||
}
|
||||
```
|
||||
|
||||
`500` — invalid registry, on both the versioned route and the alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "registry_invalid",
|
||||
"detail": "unsupported registry version: 42",
|
||||
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
|
||||
"field_path": "version",
|
||||
"source_path": "/path/to/registry.json"
|
||||
}
|
||||
```
|
||||
|
||||
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
|
||||
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
|
||||
the same detail, field, source, and remediation on a "Project registry
|
||||
unavailable" page.
|
||||
|
||||
Conditions that fail closed:
|
||||
|
||||
* file missing or unreadable;
|
||||
* invalid JSON (the remediation names line and column);
|
||||
* root not an object, or `projects` missing/empty;
|
||||
* unsupported `version`;
|
||||
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
|
||||
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
|
||||
* an unknown `status`, onboarding `state`, or health `status`.
|
||||
|
||||
## Credential rule
|
||||
|
||||
The registry stores redacted metadata only. Credential-shaped keys are
|
||||
rejected at load time, before any DTO is built, consistent with
|
||||
[safety-model.md](safety-model.md) and
|
||||
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
|
||||
and are resolved server-side by `gitea_auth`.
|
||||
|
||||
## Migrating a version 1 registry
|
||||
|
||||
1. Set `"version": 2`.
|
||||
2. Optionally add `"status"` per project (omitted means `active`).
|
||||
3. Optionally add `"remote_name"` per project.
|
||||
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
|
||||
means `pending` and `true`).
|
||||
5. Optionally add `"last_seen_health"`.
|
||||
|
||||
No step is mandatory: a version 1 file keeps loading. Bumping the version only
|
||||
declares that the file may use the v2 fields.
|
||||
+2250
-70
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,18 @@ 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"
|
||||
# #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_RECOVER_DIRTY_ORPHANED,
|
||||
SOURCE_DIRTY_SAME_CLAIMANT_REBIND,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
+130
-8
@@ -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:
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Exact-owner renewal of an expired author issue lease (#760).
|
||||
|
||||
An author issue lease carries an absolute wall-clock expiry stamped once at
|
||||
lock time. The PID recorded alongside it is the long-lived MCP daemon, not the
|
||||
authoring task, so a lease that expires while its daemon is still up is the
|
||||
ordinary case for any author task that outlives the TTL — not an anomaly.
|
||||
|
||||
Before this module, that case was unreachable.
|
||||
``issue_lock_store.assess_same_issue_lease_conflict`` computed same-owner
|
||||
evidence and then returned on the expired branch before consulting it, and
|
||||
``assess_expired_lock_reclaim`` only permits takeover on a dead PID or a
|
||||
missing worktree. An exact owner whose daemon is alive and whose worktree is
|
||||
present satisfied neither, so its own lock became permanently unmodifiable
|
||||
through sanctioned tools.
|
||||
|
||||
This module is the pure evidence assessor for that one narrow case. It answers
|
||||
a single question: may *this* session renew a lease it can prove it already
|
||||
owns? It performs no mutation and no network I/O, and it never trusts a caller
|
||||
assertion — every field is compared against durable lock state or a live
|
||||
observation supplied by the caller and gathered server-side.
|
||||
|
||||
Deliberate boundaries:
|
||||
|
||||
* **Renewal is not takeover.** A refusal here never widens what
|
||||
``assess_expired_lock_reclaim`` already allows; foreign expired locks keep
|
||||
requiring a dead PID or missing worktree (#760 AC11), and a *live* foreign
|
||||
lease stays non-recoverable by construction because only an expired lease is
|
||||
ever a candidate (AC12).
|
||||
* **PID liveness is never authorization.** A live recorded PID proves the
|
||||
daemon is up, nothing more. It is recorded as evidence and is neither
|
||||
necessary nor sufficient for renewal (AC16).
|
||||
* **Absolute expiry is preserved.** Renewal issues a new absolute expiry from
|
||||
the moment of the write. It does not introduce sliding heartbeat renewal,
|
||||
lease generations as fencing tokens, or a shared cross-role lifecycle — that
|
||||
is #790's scope and is deliberately not implemented here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from issue_lock_store import AUTHOR_ISSUE_WORK_LEASE, is_lease_expired, is_process_alive
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
# Outcome values.
|
||||
RENEWAL_SANCTIONED = "RENEWAL_SANCTIONED"
|
||||
NO_CANDIDATE = "NO_CANDIDATE"
|
||||
REFUSED = "REFUSED"
|
||||
|
||||
# Durable fields a lock must carry before it can be considered at all.
|
||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||
|
||||
|
||||
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 _lock_lease(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
lease = lock.get("work_lease")
|
||||
return dict(lease) if isinstance(lease, Mapping) else {}
|
||||
|
||||
|
||||
def _lock_operation_type(lock: Mapping[str, Any]) -> str:
|
||||
lease = _lock_lease(lock)
|
||||
return _text(lease.get("operation_type")) or AUTHOR_ISSUE_WORK_LEASE
|
||||
|
||||
|
||||
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_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
"""Names of durable fields that are missing or unusable."""
|
||||
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 _competing_lock_reasons(
|
||||
competing_live_locks: Iterable[Mapping[str, Any]] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
) -> list[str]:
|
||||
"""Live locks that would contend with this renewal (#760 AC7).
|
||||
|
||||
A live lock on the *same* issue cannot coexist with this expired lease, so
|
||||
any live entry naming this issue, branch, or worktree belongs to somebody
|
||||
else and refuses the renewal.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
for entry in competing_live_locks or ():
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
entry_issue = entry.get("issue_number")
|
||||
entry_branch = _text(entry.get("branch_name"))
|
||||
entry_worktree = _text(entry.get("worktree_path"))
|
||||
if entry_issue == issue_number:
|
||||
reasons.append(
|
||||
f"a live lock already exists for issue #{issue_number} "
|
||||
f"(pid {entry.get('pid')}); renewal would contend with it"
|
||||
)
|
||||
continue
|
||||
if entry_branch and entry_branch == _text(branch_name):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds branch "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if entry_worktree and _same_realpath(entry_worktree, worktree_path):
|
||||
reasons.append(
|
||||
f"live lock for issue #{entry_issue} already holds worktree "
|
||||
f"'{worktree_path}'"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def assess_exact_owner_lease_renewal(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
current_branch: str | None = None,
|
||||
porcelain_status: str = "",
|
||||
worktree_exists: bool = False,
|
||||
head_sha: str | None = None,
|
||||
remote_head_sha: str | None = None,
|
||||
pr_head_sha: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||
candidate_branches: Sequence[str] | None = None,
|
||||
current_pid: int | None = None,
|
||||
now: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether an expired lease may be renewed by its exact owner.
|
||||
|
||||
Returns a disposition dict; it never raises and never mutates. A refusal
|
||||
withholds permission, leaving every pre-existing guard to fail closed
|
||||
exactly as before — this assessment can only ever *add* permission.
|
||||
|
||||
``NO_CANDIDATE`` means the situation is not an exact-owner renewal at all
|
||||
(no lock, different issue, different operation, or an unexpired lease) and
|
||||
the caller should carry on with its normal path. ``REFUSED`` means it looked
|
||||
like one but the evidence did not hold, and ``reasons`` names exactly what
|
||||
was missing.
|
||||
"""
|
||||
evidence: dict[str, Any] = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"operation_type": operation_type,
|
||||
"identity": identity,
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
def _result(outcome: str, reasons: list[str], **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"renewal_sanctioned": outcome == RENEWAL_SANCTIONED,
|
||||
"is_candidate": outcome in (RENEWAL_SANCTIONED, REFUSED),
|
||||
"reasons": reasons,
|
||||
"evidence": {**evidence, **extra},
|
||||
}
|
||||
|
||||
if not isinstance(existing_lock, Mapping) or not existing_lock:
|
||||
return _result(NO_CANDIDATE, ["no existing lock to renew"])
|
||||
|
||||
if existing_lock.get("issue_number") != issue_number:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lock is for issue #{existing_lock.get('issue_number')}, "
|
||||
f"not #{issue_number}"
|
||||
],
|
||||
)
|
||||
|
||||
existing_operation = _lock_operation_type(existing_lock)
|
||||
if existing_operation != operation_type:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
[
|
||||
f"existing lease operation '{existing_operation}' is not "
|
||||
f"'{operation_type}'"
|
||||
],
|
||||
)
|
||||
|
||||
# Only an *expired* lease is ever a renewal candidate. An unexpired lease —
|
||||
# live, or stale by dead PID — is somebody else's problem: the first needs no
|
||||
# renewal, and the second is #753's dead-session recovery. This is also what
|
||||
# makes a live foreign lease non-recoverable here (#760 AC12).
|
||||
if not is_lease_expired(existing_lock, now=now):
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
["lease has not expired; renewal does not apply"],
|
||||
)
|
||||
|
||||
malformed = _malformed_reasons(existing_lock)
|
||||
if malformed:
|
||||
return _result(
|
||||
REFUSED,
|
||||
["durable lock is missing or has unusable fields: " + ", ".join(malformed)],
|
||||
)
|
||||
|
||||
lease = _lock_lease(existing_lock)
|
||||
claimant = _lock_claimant(existing_lock)
|
||||
recorded_pid = _recorded_pid(existing_lock)
|
||||
prior_expires_at = _text(lease.get("expires_at"))
|
||||
|
||||
# #760 AC16: recorded purely as evidence. A live daemon PID is neither
|
||||
# necessary nor sufficient for renewal, and nothing below branches on it.
|
||||
recorded_pid_alive = is_process_alive(recorded_pid)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"prior_pid": recorded_pid,
|
||||
"prior_pid_alive": recorded_pid_alive,
|
||||
"prior_expires_at": prior_expires_at,
|
||||
"replacement_pid": current_pid,
|
||||
"recorded_claimant": claimant,
|
||||
"head_sha": head_sha,
|
||||
"remote_head_sha": remote_head_sha,
|
||||
"pr_head_sha": pr_head_sha,
|
||||
"pr_number": pr_number,
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
# ── AC3: exact ownership identity ──
|
||||
if _text(existing_lock.get("remote")) != _text(remote):
|
||||
reasons.append(
|
||||
f"recorded remote '{existing_lock.get('remote')}' does not match "
|
||||
f"'{remote}'"
|
||||
)
|
||||
if _text(existing_lock.get("org")) != _text(org):
|
||||
reasons.append(
|
||||
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
|
||||
)
|
||||
if _text(existing_lock.get("repo")) != _text(repo):
|
||||
reasons.append(
|
||||
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
|
||||
)
|
||||
if _text(existing_lock.get("branch_name")) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path):
|
||||
reasons.append(
|
||||
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
|
||||
f"match '{worktree_path}'"
|
||||
)
|
||||
|
||||
recorded_identity = _text(claimant.get("username"))
|
||||
recorded_profile = _text(claimant.get("profile"))
|
||||
if not recorded_identity or not recorded_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record both a claimant username and profile"
|
||||
)
|
||||
if recorded_identity and recorded_identity != _text(identity):
|
||||
reasons.append(
|
||||
f"recorded claimant '{recorded_identity}' does not match active "
|
||||
f"identity '{_text(identity) or 'unknown'}'"
|
||||
)
|
||||
if recorded_profile and recorded_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"recorded profile '{recorded_profile}' does not match active profile "
|
||||
f"'{_text(profile) or 'unknown'}'"
|
||||
)
|
||||
|
||||
# ── AC4: the registered worktree still exists, is on the branch, and is clean ──
|
||||
if not worktree_exists:
|
||||
reasons.append(f"declared worktree '{worktree_path}' does not exist")
|
||||
if _text(current_branch) != _text(branch_name):
|
||||
reasons.append(
|
||||
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
|
||||
f"'{branch_name}'"
|
||||
)
|
||||
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||
if dirty:
|
||||
reasons.append(
|
||||
"worktree has uncommitted tracked changes: " + ", ".join(sorted(dirty))
|
||||
)
|
||||
|
||||
# ── AC5/AC6: published heads must agree ──
|
||||
if not _text(head_sha):
|
||||
reasons.append("local head could not be observed")
|
||||
if not _text(remote_head_sha):
|
||||
reasons.append(
|
||||
"remote branch head could not be observed; an unpublished branch "
|
||||
"cannot prove exact-owner renewal"
|
||||
)
|
||||
if _text(head_sha) and _text(remote_head_sha) and head_sha != remote_head_sha:
|
||||
reasons.append(
|
||||
f"local head {head_sha} does not equal remote head {remote_head_sha}"
|
||||
)
|
||||
if pr_number is not None:
|
||||
if not _text(pr_head_sha):
|
||||
reasons.append(f"owning PR #{pr_number} head could not be observed")
|
||||
elif _text(head_sha) and pr_head_sha != head_sha:
|
||||
reasons.append(
|
||||
f"owning PR #{pr_number} head {pr_head_sha} does not equal local "
|
||||
f"head {head_sha}"
|
||||
)
|
||||
|
||||
# ── AC7: nothing else claims this work ──
|
||||
reasons.extend(
|
||||
_competing_lock_reasons(
|
||||
competing_live_locks,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=worktree_path,
|
||||
)
|
||||
)
|
||||
other_branches = [
|
||||
name
|
||||
for name in (candidate_branches or ())
|
||||
if _text(name) and _text(name) != _text(branch_name)
|
||||
]
|
||||
if other_branches:
|
||||
reasons.append(
|
||||
"other branches already carry this issue marker: "
|
||||
+ ", ".join(sorted(other_branches))
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return _result(REFUSED, reasons, **extra)
|
||||
|
||||
return _result(
|
||||
RENEWAL_SANCTIONED,
|
||||
[
|
||||
f"exact owner '{recorded_identity}' ({recorded_profile}) proved "
|
||||
f"ownership of issue #{issue_number} on branch '{branch_name}' from "
|
||||
f"worktree '{worktree_path}'; local, remote"
|
||||
+ (f", and PR #{pr_number}" if pr_number is not None else "")
|
||||
+ f" heads all equal {head_sha}; lease expired at "
|
||||
f"{prior_expires_at or 'unknown'}"
|
||||
],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def owning_pr_renewal_evidence(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Server-derived proof of the open PR a sanctioned renewal already owns.
|
||||
|
||||
The mirror of ``issue_lock_recovery.owning_pr_recovery_evidence`` (#755) for
|
||||
the renewal disposition. An exact-owner renewal of a published branch is, by
|
||||
construction, renewal of work that already has an open PR — so the
|
||||
duplicate-work gate's linked-open-PR blocker would otherwise discard every
|
||||
sanctioned renewal, exactly as it once discarded every sanctioned recovery.
|
||||
|
||||
Returns ``None`` unless renewal was actually granted and the evidence names
|
||||
one owning PR whose head agrees with both the local and remote heads the
|
||||
assessor accepted. Nothing is caller-supplied: every field is copied from
|
||||
evidence built out of durable lock state plus live git/Gitea observation.
|
||||
|
||||
Renewal has no descendant case — it requires the local, remote, and PR heads
|
||||
to be equal — so there is only one head to report.
|
||||
"""
|
||||
if not isinstance(assessment, Mapping):
|
||||
return None
|
||||
if assessment.get("outcome") != RENEWAL_SANCTIONED:
|
||||
return None
|
||||
if not assessment.get("renewal_sanctioned"):
|
||||
return None
|
||||
|
||||
evidence = assessment.get("evidence") or {}
|
||||
branch_name = _text(evidence.get("branch_name"))
|
||||
pr_head = _text(evidence.get("pr_head_sha"))
|
||||
local_head = _text(evidence.get("head_sha"))
|
||||
remote_head = _text(evidence.get("remote_head_sha"))
|
||||
raw_pr_number = evidence.get("pr_number")
|
||||
|
||||
if raw_pr_number is None or not branch_name or not pr_head:
|
||||
return None
|
||||
# The assessor already required these to agree. Re-check, so a truncated or
|
||||
# hand-built evidence map can never authorize an exemption.
|
||||
if pr_head != local_head or pr_head != remote_head:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(evidence.get("issue_number"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": pr_head,
|
||||
"accepted_head": pr_head,
|
||||
"head_relation": "equal",
|
||||
}
|
||||
|
||||
|
||||
def build_renewal_record(
|
||||
assessment: Mapping[str, Any] | None,
|
||||
*,
|
||||
renewed_at: str,
|
||||
new_expires_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable audit record for a sanctioned renewal (#760 AC9).
|
||||
|
||||
Records both sides of the transition — prior PID and expiry, replacement PID
|
||||
and new expiry — so a renewed lock is never mistakable for an original
|
||||
claim, and so the evidence the waiver was granted on stays inspectable.
|
||||
"""
|
||||
data = dict(assessment or {})
|
||||
evidence = dict(data.get("evidence") or {})
|
||||
recorded_claimant = dict(evidence.get("recorded_claimant") or {})
|
||||
return {
|
||||
"renewed": bool(data.get("renewal_sanctioned")),
|
||||
"renewed_at": renewed_at,
|
||||
"prior_pid": evidence.get("prior_pid"),
|
||||
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||
"prior_expires_at": evidence.get("prior_expires_at"),
|
||||
"replacement_pid": evidence.get("replacement_pid"),
|
||||
"new_expires_at": new_expires_at,
|
||||
"identity": recorded_claimant.get("username"),
|
||||
"profile": recorded_claimant.get("profile"),
|
||||
"branch_name": evidence.get("branch_name"),
|
||||
"worktree_path": evidence.get("worktree_path"),
|
||||
"head_sha": evidence.get("head_sha"),
|
||||
"remote_head_sha": evidence.get("remote_head_sha"),
|
||||
"pr_head_sha": evidence.get("pr_head_sha"),
|
||||
"pr_number": evidence.get("pr_number"),
|
||||
"reason": "expired lease renewed by its exact recorded owner",
|
||||
"proof": list(data.get("reasons") or []),
|
||||
}
|
||||
|
||||
|
||||
def format_renewal_refusal(assessment: Mapping[str, Any] | None) -> str:
|
||||
"""One-line refusal summary for a blocked caller."""
|
||||
data = dict(assessment or {})
|
||||
reasons = list(data.get("reasons") or [])
|
||||
if not reasons:
|
||||
return "exact-owner lease renewal was not available (no evidence recorded)"
|
||||
return "exact-owner lease renewal refused: " + "; ".join(reasons)
|
||||
+967
-37
File diff suppressed because it is too large
Load Diff
+203
-3
@@ -145,6 +145,184 @@ def read_head_ancestry(
|
||||
return result
|
||||
|
||||
|
||||
def read_merge_sync_provenance(
|
||||
worktree_path: str,
|
||||
*,
|
||||
prior_head_sha: str | None,
|
||||
synced_head_sha: str | None,
|
||||
remote: str | None = None,
|
||||
) -> dict:
|
||||
"""Observe whether ``synced_head_sha`` is a sanctioned merge-sync of a base
|
||||
into the branch above ``prior_head_sha`` (#871/#872).
|
||||
|
||||
Reports server-derived git facts only; the recovery disposition lives in
|
||||
``issue_lock_recovery``. All comparisons are executed locally in the
|
||||
declared worktree -- nothing is taken from caller parameters.
|
||||
|
||||
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()
|
||||
target_remote = (remote or "").strip() or None
|
||||
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)
|
||||
if not result["synced_present"] and path and os.path.isdir(path):
|
||||
if target_remote:
|
||||
subprocess.run(
|
||||
["git", "-C", path, "fetch", target_remote, "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
result["synced_present"] = _present(synced)
|
||||
if not result["synced_present"]:
|
||||
subprocess.run(
|
||||
["git", "-C", path, "fetch", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
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,
|
||||
*,
|
||||
@@ -285,6 +463,7 @@ def assess_issue_lock_worktree(
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree.
|
||||
|
||||
@@ -296,6 +475,19 @@ def assess_issue_lock_worktree(
|
||||
by construction and could never satisfy it. Every other precondition —
|
||||
notably worktree cleanliness — still applies unchanged, and brand-new issue
|
||||
claims keep the full base-equivalence requirement.
|
||||
|
||||
``renewal_sanctioned`` waives base-equivalence on exactly the same grounds
|
||||
for the other proven-ownership case (#760): ``issue_lock_renewal`` has shown
|
||||
that an *expired* lease is being renewed by its exact recorded owner — same
|
||||
remote, org, repo, issue, operation, branch, realpath-normalized worktree,
|
||||
claimant username and profile — with the local head matching the remote head
|
||||
and any owning PR head. Such a branch carries committed work for the same
|
||||
reason a recovered one does, so it can never be base-equivalent either.
|
||||
|
||||
Both waivers relax this one requirement and nothing else. Neither is
|
||||
caller-supplied: each is computed server-side from durable lock state plus
|
||||
live observation. With both False every precondition applies exactly as
|
||||
before.
|
||||
"""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
@@ -314,9 +506,12 @@ def assess_issue_lock_worktree(
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if recovery_sanctioned:
|
||||
if recovery_sanctioned or renewal_sanctioned:
|
||||
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||
# against the durable lock record instead (#753).
|
||||
# against the durable lock record instead — by dead-session recovery
|
||||
# (#753) or by exact-owner renewal of an expired lease (#760). Every
|
||||
# other precondition above and below still applies; cleanliness in
|
||||
# particular is checked before this branch and is never waived.
|
||||
pass
|
||||
elif base_equivalent is False:
|
||||
reasons.append(
|
||||
@@ -347,6 +542,7 @@ def assess_issue_lock_worktree(
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
|
||||
|
||||
@@ -406,6 +602,7 @@ def _assessment(
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
@@ -418,7 +615,10 @@ def _assessment(
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
"recovery_sanctioned": recovery_sanctioned,
|
||||
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||
"renewal_sanctioned": renewal_sanctioned,
|
||||
# Either proven-ownership waiver relaxes base-equivalence; the two are
|
||||
# reported separately so an audit can tell which one applied.
|
||||
"base_equivalence_waived": bool(recovery_sanctioned or renewal_sanctioned),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+209
-13
@@ -39,6 +39,7 @@ SAFE_RELEASE_OWNED = "release_owned"
|
||||
SAFE_STALE_PROMPT = "stale_prompt_lease"
|
||||
SAFE_UNKNOWN = "inspect_only"
|
||||
SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative"
|
||||
SAFE_CONSUME_CROSS_ROLE = "consume_cross_role_handoff"
|
||||
|
||||
LEASE_STATUS_ACTIVE = "active"
|
||||
LEASE_STATUS_RELEASED = "released"
|
||||
@@ -250,6 +251,23 @@ def decide_safe_next_action(
|
||||
"same_owner": True,
|
||||
"also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED],
|
||||
}
|
||||
handoff = is_pending_cross_role_handoff({"lease": lease})
|
||||
if handoff:
|
||||
return {
|
||||
"safe_next_action": SAFE_CONSUME_CROSS_ROLE,
|
||||
"reasons": [
|
||||
f"controller allocation pending handoff (freshness={status}); "
|
||||
"required-role worker may consume without abandon/reassign; "
|
||||
f"required_role={handoff['required_role']}"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": False,
|
||||
"owner_session_id": owner,
|
||||
"required_role": handoff["required_role"],
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"also_allowed": [SAFE_ABANDON_ALLOWED],
|
||||
}
|
||||
return {
|
||||
"safe_next_action": SAFE_ABANDON_ALLOWED,
|
||||
"reasons": [
|
||||
@@ -272,6 +290,24 @@ def decide_safe_next_action(
|
||||
}
|
||||
|
||||
if not same_owner and status == "active":
|
||||
# #843: pending cross-role handoff is consumable by required role
|
||||
handoff = is_pending_cross_role_handoff({"lease": lease})
|
||||
if handoff:
|
||||
return {
|
||||
"safe_next_action": SAFE_CONSUME_CROSS_ROLE,
|
||||
"reasons": [
|
||||
"controller cross-role allocation pending handoff; "
|
||||
f"required_role={handoff['required_role']}; "
|
||||
"consume via gitea_adopt_workflow_lease without "
|
||||
"abandonment or sharing the controller session"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": False,
|
||||
"owner_session_id": owner,
|
||||
"required_role": handoff["required_role"],
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
}
|
||||
return {
|
||||
"safe_next_action": SAFE_WAIT_FOREIGN,
|
||||
"reasons": [
|
||||
@@ -440,6 +476,84 @@ def list_active_leases(
|
||||
}
|
||||
|
||||
|
||||
|
||||
def parse_lease_provenance(lease_or_state: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return durable lease provenance dict (empty when absent/unparseable)."""
|
||||
if not lease_or_state:
|
||||
return {}
|
||||
if "provenance" in lease_or_state and isinstance(lease_or_state.get("provenance"), dict):
|
||||
return dict(lease_or_state["provenance"])
|
||||
raw = None
|
||||
if "provenance_json" in lease_or_state:
|
||||
raw = lease_or_state.get("provenance_json")
|
||||
elif "lease" in lease_or_state and isinstance(lease_or_state.get("lease"), Mapping):
|
||||
raw = lease_or_state["lease"].get("provenance_json")
|
||||
if not raw:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return dict(loaded) if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
def is_pending_cross_role_handoff(
|
||||
state: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return handoff evidence when a controller allocation awaits consume (#843).
|
||||
|
||||
A pending handoff is identified by durable provenance written at
|
||||
cross-role apply time — not by title heuristics or session-id guessing.
|
||||
"""
|
||||
if not state:
|
||||
return None
|
||||
lease = state.get("lease") if isinstance(state.get("lease"), Mapping) else state
|
||||
if not isinstance(lease, Mapping):
|
||||
return None
|
||||
status = str(lease.get("status") or "").strip().lower()
|
||||
if status in (LEASE_STATUS_ABANDONED, LEASE_STATUS_RELEASED, LEASE_STATUS_EXPIRED):
|
||||
return None
|
||||
prov = parse_lease_provenance(state)
|
||||
if not prov and isinstance(lease, Mapping):
|
||||
prov = parse_lease_provenance(lease)
|
||||
if not prov.get("cross_role_handoff"):
|
||||
return None
|
||||
handoff_status = str(prov.get("handoff_status") or "pending").strip().lower()
|
||||
if handoff_status != "pending":
|
||||
return None
|
||||
adopted_by = (
|
||||
lease.get("adopted_by_session_id")
|
||||
or prov.get("adopted_by_session_id")
|
||||
or ""
|
||||
)
|
||||
if str(adopted_by).strip():
|
||||
return None
|
||||
required_role = str(
|
||||
prov.get("required_role") or lease.get("role") or ""
|
||||
).strip().lower()
|
||||
if not required_role:
|
||||
return None
|
||||
return {
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"required_role": required_role,
|
||||
"allocating_session_id": str(
|
||||
prov.get("allocating_session_id") or lease.get("session_id") or ""
|
||||
),
|
||||
"allocating_role": str(prov.get("allocating_role") or "controller"),
|
||||
"lease_id": str(lease.get("lease_id") or ""),
|
||||
"assignment_id": (
|
||||
str(state["assignment"]["assignment_id"])
|
||||
if isinstance(state.get("assignment"), Mapping)
|
||||
and state["assignment"].get("assignment_id")
|
||||
else None
|
||||
),
|
||||
"provenance": prov,
|
||||
}
|
||||
|
||||
|
||||
def adopt_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
@@ -450,8 +564,17 @@ def adopt_lease(
|
||||
expected_head_sha: str | None = None,
|
||||
owner_pid: int | None = None,
|
||||
operator_authorized: bool = False,
|
||||
adopter_profile_name: str | None = None,
|
||||
adopter_namespace: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Sanctioned adopt path with provenance; never silent foreign steal."""
|
||||
"""Sanctioned adopt path with provenance; never silent foreign steal.
|
||||
|
||||
#843 F1: for a pending cross-role handoff, ``role`` must be the
|
||||
authoritative profile-derived role supplied by the MCP boundary — never
|
||||
caller-asserted authority. When the handoff provenance declares
|
||||
``required_profile`` / ``required_namespace`` and the caller context is
|
||||
provided, both are validated exactly; a mismatch fails closed.
|
||||
"""
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
raise LeaseLifecycleError(
|
||||
@@ -463,11 +586,8 @@ def adopt_lease(
|
||||
owner = str(lease.get("session_id") or "")
|
||||
same_owner = owner == str(adopter_session_id)
|
||||
|
||||
if freshness["freshness"] == "active" and not same_owner:
|
||||
raise LeaseLifecycleError(
|
||||
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||
f"{owner} (fail closed)"
|
||||
)
|
||||
handoff = is_pending_cross_role_handoff(state)
|
||||
adopter_role = (role or "").strip().lower()
|
||||
|
||||
if freshness["freshness"] in ("abandoned", "released"):
|
||||
raise LeaseLifecycleError(
|
||||
@@ -475,13 +595,64 @@ def adopt_lease(
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
# Expired or stale: require abandon-style safety before ownership transfer
|
||||
# when not same owner; same owner may reclaim.
|
||||
if not same_owner and freshness["freshness"] in (
|
||||
if handoff and not same_owner:
|
||||
# Terminal statuses already rejected above. Freshness may be
|
||||
# active OR stale_dead_process (controller exited) — both are
|
||||
# consumable without abandonment when handoff is still pending.
|
||||
if freshness["freshness"] not in (
|
||||
"active",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
):
|
||||
raise LeaseLifecycleError(
|
||||
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||
"terminal or non-active allocation cannot be handoff-consumed "
|
||||
"(fail closed)"
|
||||
)
|
||||
required = handoff["required_role"]
|
||||
if adopter_role != required:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong role for cross-role handoff consume of {lease_id}: "
|
||||
f"required={required} adopter={adopter_role or 'none'} "
|
||||
"(fail closed)"
|
||||
)
|
||||
# #843 F1: provenance profile/namespace restrictions are validated
|
||||
# against the authoritative caller context when declared. Caller
|
||||
# input can never widen authority; a mismatch fails closed.
|
||||
handoff_prov = handoff.get("provenance") or {}
|
||||
required_profile = str(
|
||||
handoff_prov.get("required_profile") or ""
|
||||
).strip()
|
||||
if required_profile and adopter_profile_name is not None:
|
||||
if str(adopter_profile_name).strip() != required_profile:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong profile for cross-role handoff consume of "
|
||||
f"{lease_id}: required_profile={required_profile} "
|
||||
f"adopter_profile={adopter_profile_name} (fail closed)"
|
||||
)
|
||||
required_namespace = str(
|
||||
handoff_prov.get("required_namespace") or ""
|
||||
).strip()
|
||||
if required_namespace and adopter_namespace is not None:
|
||||
if str(adopter_namespace).strip() != required_namespace:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong namespace for cross-role handoff consume of "
|
||||
f"{lease_id}: required_namespace={required_namespace} "
|
||||
f"adopter_namespace={adopter_namespace} (fail closed)"
|
||||
)
|
||||
reason = "cross-role-handoff-consume"
|
||||
elif freshness["freshness"] == "active" and not same_owner:
|
||||
raise LeaseLifecycleError(
|
||||
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||
f"{owner} (fail closed)"
|
||||
)
|
||||
elif not same_owner and freshness["freshness"] in (
|
||||
"expired",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
):
|
||||
# Expired or stale (non-handoff): require abandon-style safety before
|
||||
# ownership transfer when not same owner; same owner may reclaim.
|
||||
if not operator_authorized and freshness["freshness"] == "expired":
|
||||
# Deterministic reclaim of expired foreign lease is allowed
|
||||
# without operator flag (sanctioned expire reclaim).
|
||||
@@ -492,6 +663,9 @@ def adopt_lease(
|
||||
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||
"use abandon with proof before foreign adopt (fail closed)"
|
||||
)
|
||||
reason = "sanctioned-reclaim-adopt"
|
||||
else:
|
||||
reason = "owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||
|
||||
provenance = build_adopt_provenance(
|
||||
adopted_from_session_id=owner,
|
||||
@@ -504,10 +678,14 @@ def adopt_lease(
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha or lease.get("expected_head_sha"),
|
||||
prior_lease_id=lease_id,
|
||||
reason=(
|
||||
"owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||
),
|
||||
reason=reason,
|
||||
)
|
||||
if handoff and not same_owner:
|
||||
provenance["cross_role_handoff"] = True
|
||||
provenance["handoff_status"] = "adopted"
|
||||
provenance["required_role"] = handoff["required_role"]
|
||||
provenance["allocating_session_id"] = handoff["allocating_session_id"]
|
||||
provenance["allocating_role"] = handoff["allocating_role"]
|
||||
|
||||
result = db.adopt_lease(
|
||||
lease_id=lease_id,
|
||||
@@ -518,7 +696,7 @@ def adopt_lease(
|
||||
owner_pid=owner_pid if owner_pid is not None else os.getpid(),
|
||||
provenance=provenance,
|
||||
)
|
||||
return {
|
||||
out = {
|
||||
"success": True,
|
||||
"outcome": result.get("outcome"),
|
||||
"same_owner": same_owner,
|
||||
@@ -531,6 +709,24 @@ def adopt_lease(
|
||||
"comment_lease_only": False,
|
||||
"reasons": result.get("reasons") or [],
|
||||
}
|
||||
if handoff and not same_owner:
|
||||
out["cross_role_handoff"] = True
|
||||
out["handoff_status"] = "adopted"
|
||||
out["required_role"] = handoff["required_role"]
|
||||
out["adopted_by_session_id"] = adopter_session_id
|
||||
out["adopted_from_session_id"] = owner
|
||||
lease_row = result.get("lease") or {}
|
||||
if isinstance(lease_row, Mapping):
|
||||
out["read_after_write"] = {
|
||||
"lease_id": lease_row.get("lease_id"),
|
||||
"session_id": lease_row.get("session_id"),
|
||||
"role": lease_row.get("role"),
|
||||
"status": lease_row.get("status"),
|
||||
"adopted_by_session_id": lease_row.get("adopted_by_session_id"),
|
||||
"adopted_from_session_id": lease_row.get("adopted_from_session_id"),
|
||||
"phase": lease_row.get("phase"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def release_lease(
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
"""Central lease policy configuration (#790 Slice A, AC-N7).
|
||||
|
||||
The single authoritative source for every lease duration in the project. Before
|
||||
this module the numbers were scattered: a four-hour author TTL was declared
|
||||
twice (``issue_lock_store`` and ``gitea_mcp_server``), the reviewer/merger
|
||||
sliding window lived in ``reviewer_pr_lease``, the conflict-fix window in
|
||||
``pr_work_lease``, and the control-plane default in ``control_plane_db``.
|
||||
Nothing tied them together, so tuning one class silently diverged from the
|
||||
others and no reader could answer "how long does a lease live?" without
|
||||
grepping four files.
|
||||
|
||||
AC-N7 requires that this configuration exist *before* the first heartbeat and
|
||||
TTL behavior that reads from it, so it ships in Slice A rather than trailing the
|
||||
code it governs.
|
||||
|
||||
Deliberate boundaries:
|
||||
|
||||
* **Declaration is not rewiring.** Every task class is declared here, but only
|
||||
those with ``heartbeat_lifecycle_active`` were migrated onto the shared
|
||||
heartbeat lifecycle in Slice A — currently ``author_issue_work`` alone.
|
||||
Reviewer, merger, and conflict-fix leases keep their own existing behavior
|
||||
until Slice C moves them; their numbers are recorded here so the two cannot
|
||||
drift apart unnoticed, and ``tests/test_issue_790_lease_policy.py`` asserts
|
||||
the recorded values still equal the constants those modules use.
|
||||
* **No policy decision lives here.** This module answers "how long", never "may
|
||||
this session proceed". Freshness, reclaim, and renewal dispositions stay in
|
||||
``issue_lock_store``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# Task classes. Only the first is migrated onto the shared lifecycle in Slice A.
|
||||
TASK_CLASS_AUTHOR_ISSUE_WORK = "author_issue_work"
|
||||
TASK_CLASS_REVIEWER_PR = "reviewer_pr"
|
||||
TASK_CLASS_MERGER_PR = "merger_pr"
|
||||
TASK_CLASS_CONFLICT_FIX = "conflict_fix"
|
||||
|
||||
# Durable marker for a lease minted under the shared heartbeat lifecycle.
|
||||
#
|
||||
# #790 AC-N8: this explicit marker — never a timestamp comparison — is what
|
||||
# distinguishes a heartbeat-lifecycle lease from a legacy one. A lock written
|
||||
# before this lifecycle existed carries no marker and reads as
|
||||
# ``LIFECYCLE_LEGACY``.
|
||||
LIFECYCLE_HEARTBEAT_V1 = "heartbeat-v1"
|
||||
LIFECYCLE_LEGACY = "legacy"
|
||||
|
||||
_ENV_PREFIX = "GITEA_LEASE_POLICY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeasePolicy:
|
||||
"""Durations governing one task class.
|
||||
|
||||
All intervals are minutes except ``absolute_cap_hours``. ``None`` for the
|
||||
cap means the class has no maximum continuous duration.
|
||||
"""
|
||||
|
||||
task_class: str
|
||||
initial_ttl_minutes: float
|
||||
heartbeat_cadence_minutes: float
|
||||
stale_warning_minutes: float
|
||||
missed_heartbeat_grace_minutes: float
|
||||
absolute_cap_hours: float | None
|
||||
recovery_grace_minutes: float
|
||||
terminal_race_drain_minutes: float
|
||||
terminal_retirement_eligible: bool
|
||||
heartbeat_lifecycle_active: bool
|
||||
|
||||
|
||||
# Defaults. ``author_issue_work`` adopts the reviewer window proven by #747
|
||||
# rather than inventing new numbers: a lease expires 10 minutes after its last
|
||||
# valid heartbeat, warns at half that, and an actively heartbeating session is
|
||||
# never evicted. The prior value was a fixed four hours (240 minutes) that no
|
||||
# heartbeat could shorten — the defect this issue exists to correct.
|
||||
_DEFAULTS: dict[str, LeasePolicy] = {
|
||||
TASK_CLASS_AUTHOR_ISSUE_WORK: LeasePolicy(
|
||||
task_class=TASK_CLASS_AUTHOR_ISSUE_WORK,
|
||||
initial_ttl_minutes=10.0,
|
||||
heartbeat_cadence_minutes=2.0,
|
||||
stale_warning_minutes=5.0,
|
||||
missed_heartbeat_grace_minutes=10.0,
|
||||
absolute_cap_hours=8.0,
|
||||
recovery_grace_minutes=10.0,
|
||||
terminal_race_drain_minutes=2.0,
|
||||
terminal_retirement_eligible=True,
|
||||
heartbeat_lifecycle_active=True,
|
||||
),
|
||||
# Declared, not rewired. These mirror reviewer_pr_lease.LEASE_TTL_MINUTES
|
||||
# and STALE_WARNING_MINUTES; Slice C migrates the call sites.
|
||||
TASK_CLASS_REVIEWER_PR: LeasePolicy(
|
||||
task_class=TASK_CLASS_REVIEWER_PR,
|
||||
initial_ttl_minutes=10.0,
|
||||
heartbeat_cadence_minutes=2.0,
|
||||
stale_warning_minutes=5.0,
|
||||
missed_heartbeat_grace_minutes=10.0,
|
||||
absolute_cap_hours=None,
|
||||
recovery_grace_minutes=10.0,
|
||||
terminal_race_drain_minutes=2.0,
|
||||
terminal_retirement_eligible=False,
|
||||
heartbeat_lifecycle_active=False,
|
||||
),
|
||||
TASK_CLASS_MERGER_PR: LeasePolicy(
|
||||
task_class=TASK_CLASS_MERGER_PR,
|
||||
initial_ttl_minutes=10.0,
|
||||
heartbeat_cadence_minutes=2.0,
|
||||
stale_warning_minutes=5.0,
|
||||
missed_heartbeat_grace_minutes=10.0,
|
||||
absolute_cap_hours=None,
|
||||
recovery_grace_minutes=10.0,
|
||||
terminal_race_drain_minutes=2.0,
|
||||
terminal_retirement_eligible=False,
|
||||
heartbeat_lifecycle_active=False,
|
||||
),
|
||||
# Mirrors pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES. Deliberately left
|
||||
# at its current window; shortening it is Slice C's call, not this slice's.
|
||||
TASK_CLASS_CONFLICT_FIX: LeasePolicy(
|
||||
task_class=TASK_CLASS_CONFLICT_FIX,
|
||||
initial_ttl_minutes=120.0,
|
||||
heartbeat_cadence_minutes=2.0,
|
||||
stale_warning_minutes=5.0,
|
||||
missed_heartbeat_grace_minutes=10.0,
|
||||
absolute_cap_hours=None,
|
||||
recovery_grace_minutes=10.0,
|
||||
terminal_race_drain_minutes=2.0,
|
||||
terminal_retirement_eligible=False,
|
||||
heartbeat_lifecycle_active=False,
|
||||
),
|
||||
}
|
||||
|
||||
_NUMERIC_FIELDS = (
|
||||
"initial_ttl_minutes",
|
||||
"heartbeat_cadence_minutes",
|
||||
"stale_warning_minutes",
|
||||
"missed_heartbeat_grace_minutes",
|
||||
"absolute_cap_hours",
|
||||
"recovery_grace_minutes",
|
||||
"terminal_race_drain_minutes",
|
||||
)
|
||||
|
||||
|
||||
def env_var_name(task_class: str, field: str) -> str:
|
||||
"""Environment variable that overrides one field of one task class."""
|
||||
return f"{_ENV_PREFIX}_{task_class.upper()}_{field.upper()}"
|
||||
|
||||
|
||||
def _override(task_class: str, field: str, default: float | None) -> float | None:
|
||||
"""Read one override, falling back to *default* on anything unusable.
|
||||
|
||||
A malformed or non-positive override is ignored rather than raised: a typo
|
||||
in an environment variable must not be able to mint a zero-length lease that
|
||||
makes every claim instantly reclaimable, nor crash the server at import.
|
||||
"""
|
||||
raw = (os.environ.get(env_var_name(task_class, field)) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if value <= 0:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def policy_for(task_class: str) -> LeasePolicy:
|
||||
"""Return the effective policy for *task_class*.
|
||||
|
||||
Unknown task classes fall back to the author policy, which is the most
|
||||
conservative migrated class, rather than raising — a new caller must never
|
||||
be able to crash a lock write by naming a class this table has not learned.
|
||||
"""
|
||||
key = str(task_class or "").strip() or TASK_CLASS_AUTHOR_ISSUE_WORK
|
||||
base = _DEFAULTS.get(key) or _DEFAULTS[TASK_CLASS_AUTHOR_ISSUE_WORK]
|
||||
resolved = {
|
||||
field: _override(base.task_class, field, getattr(base, field))
|
||||
for field in _NUMERIC_FIELDS
|
||||
}
|
||||
if all(resolved[field] == getattr(base, field) for field in _NUMERIC_FIELDS):
|
||||
return base
|
||||
return LeasePolicy(
|
||||
task_class=base.task_class,
|
||||
terminal_retirement_eligible=base.terminal_retirement_eligible,
|
||||
heartbeat_lifecycle_active=base.heartbeat_lifecycle_active,
|
||||
**resolved,
|
||||
)
|
||||
|
||||
|
||||
def known_task_classes() -> tuple[str, ...]:
|
||||
"""Every declared task class, migrated or not."""
|
||||
return tuple(_DEFAULTS)
|
||||
|
||||
|
||||
def describe(task_class: str) -> dict[str, Any]:
|
||||
"""Serializable view of a policy, for audit records and tool payloads."""
|
||||
policy = policy_for(task_class)
|
||||
return {
|
||||
"task_class": policy.task_class,
|
||||
"initial_ttl_minutes": policy.initial_ttl_minutes,
|
||||
"heartbeat_cadence_minutes": policy.heartbeat_cadence_minutes,
|
||||
"stale_warning_minutes": policy.stale_warning_minutes,
|
||||
"missed_heartbeat_grace_minutes": policy.missed_heartbeat_grace_minutes,
|
||||
"absolute_cap_hours": policy.absolute_cap_hours,
|
||||
"recovery_grace_minutes": policy.recovery_grace_minutes,
|
||||
"terminal_race_drain_minutes": policy.terminal_race_drain_minutes,
|
||||
"terminal_retirement_eligible": policy.terminal_retirement_eligible,
|
||||
"heartbeat_lifecycle_active": policy.heartbeat_lifecycle_active,
|
||||
"lifecycle_version": LIFECYCLE_HEARTBEAT_V1,
|
||||
}
|
||||
+213
-17
@@ -24,12 +24,50 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Live-remote head cache: the parity gate runs on every mutation and every
|
||||
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
|
||||
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
|
||||
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
|
||||
_REMOTE_HEAD_TTL = 60.0
|
||||
|
||||
# When True, ``read_remote_master_head`` never performs ``git ls-remote`` unless
|
||||
# ``GITEA_TEST_LIVE_REMOTE_HEAD`` is set. Conftest enables this suite-wide so
|
||||
# feature worktrees (whose HEAD differs from live master) cannot flip legacy
|
||||
# runtime-context assertions to live_stale, and so unit tests never depend on
|
||||
# a live network (PR #788 F1/F2 / issue #610). Module-level (not env-only) so
|
||||
# ``patch.dict(os.environ, …, clear=True)`` cannot re-enable the probe.
|
||||
_HERMETIC_TEST_MODE: bool = False
|
||||
|
||||
|
||||
def _clear_remote_head_cache() -> None:
|
||||
"""Reset the live-remote head cache (test isolation / forced refresh)."""
|
||||
_REMOTE_HEAD_CACHE.clear()
|
||||
|
||||
|
||||
def set_hermetic_test_mode(enabled: bool) -> None:
|
||||
"""Enable or disable suite-wide hermetic live-remote reads (tests only)."""
|
||||
global _HERMETIC_TEST_MODE
|
||||
_HERMETIC_TEST_MODE = bool(enabled)
|
||||
_clear_remote_head_cache()
|
||||
|
||||
|
||||
def hermetic_test_mode() -> bool:
|
||||
"""Return whether hermetic live-remote reads are active."""
|
||||
return bool(_HERMETIC_TEST_MODE)
|
||||
|
||||
|
||||
# Environment escape hatches (ops + tests):
|
||||
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
|
||||
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
|
||||
# GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE -> opt a single test into a real ls-remote
|
||||
# even when hermetic mode is on (rare; prefer ENV_TEST_LIVE_REMOTE_HEAD).
|
||||
ENV_TEST_ALLOW_LIVE_REMOTE_PROBE = "GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
@@ -58,6 +96,75 @@ def read_git_head(root: str) -> str | None:
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def read_remote_master_head(
|
||||
root: str,
|
||||
remote: str = "origin",
|
||||
branch: str = "master",
|
||||
ttl: float = _REMOTE_HEAD_TTL,
|
||||
) -> str | None:
|
||||
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
|
||||
|
||||
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
|
||||
a daemon that is behind the live remote master apart from one whose local
|
||||
checkout simply hasn't been pulled. ``None`` means the live head could not
|
||||
be resolved (offline, no such remote, git unavailable, error) -- callers
|
||||
must treat unknown live state as *not mutation-safe* while never blocking
|
||||
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
|
||||
precedence so the wiring can be exercised deterministically and offline.
|
||||
|
||||
The result is cached for *ttl* seconds per (root, remote, branch) so the
|
||||
gate does not run a network probe on every mutation/read (``ttl=0`` forces
|
||||
a live probe). Both hits and ``None`` misses are cached to bound offline
|
||||
latency; the env override bypasses the cache and the subprocess entirely.
|
||||
|
||||
Under suite hermetic mode (``set_hermetic_test_mode(True)``, set by
|
||||
conftest) a missing override returns ``None`` without network I/O so
|
||||
feature-worktree test runs cannot observe live_stale against real master
|
||||
(PR #788 F1) and unit tests stay offline (F2). Opt out with an explicit
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` pin or ``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if _HERMETIC_TEST_MODE and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
# Hermetic default: live head unknown. live_stale stays False;
|
||||
# mutation_safe is False when live is unknown (documented #610 note).
|
||||
return None
|
||||
# Defense in depth: even without the module flag, never probe while pytest
|
||||
# is running unless the test opted into a real probe or set an override.
|
||||
if (os.environ.get("PYTEST_CURRENT_TEST") or "").strip() and not (
|
||||
os.environ.get(ENV_TEST_ALLOW_LIVE_REMOTE_PROBE) or ""
|
||||
).strip():
|
||||
return None
|
||||
if not root:
|
||||
return None
|
||||
key = (root, remote, branch)
|
||||
now = time.monotonic()
|
||||
if ttl > 0:
|
||||
cached = _REMOTE_HEAD_CACHE.get(key)
|
||||
if cached is not None and (now - cached[0]) < ttl:
|
||||
return cached[1]
|
||||
sha: str | None = None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
lines = (res.stdout or "").strip().splitlines()
|
||||
if lines:
|
||||
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
|
||||
except Exception:
|
||||
sha = None
|
||||
_REMOTE_HEAD_CACHE[key] = (now, sha)
|
||||
return sha
|
||||
|
||||
|
||||
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||
"""Capture the process source-tree baseline once at server startup.
|
||||
|
||||
@@ -72,18 +179,38 @@ def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
def assess_master_parity(
|
||||
startup: dict | None,
|
||||
current_head: str | None,
|
||||
live_remote_head: str | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
Pure: all HEADs are supplied by the caller. Returns a structured result:
|
||||
|
||||
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``determinable`` -- whether both local HEADs were known well enough to
|
||||
compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
|
||||
#610 adds live-remote awareness so a daemon that is stale relative to the
|
||||
*live* remote master cannot report a mutation-safe result even when the
|
||||
local checkout HEAD still matches the daemon's startup commit:
|
||||
|
||||
- ``daemon_start_head`` -- the commit the running process started at
|
||||
(alias of ``startup_head``, named for clarity in reports).
|
||||
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
|
||||
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
|
||||
could not be fetched.
|
||||
- ``live_known`` -- whether the live remote target was resolved.
|
||||
- ``live_stale`` -- the live remote master has advanced past the running
|
||||
process (daemon is behind live master) even if local parity is green.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
|
||||
target all agree; the only state in which a mutation may rely on parity.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
@@ -91,32 +218,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, startup_head, current_head, reasons)
|
||||
local_in_parity = startup_head == current_head
|
||||
local_stale = not local_in_parity
|
||||
if local_stale:
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the "
|
||||
f"workspace master is now {_short(current_head)}; restart the "
|
||||
f"server to load the current capability gates")
|
||||
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||
f"master is now {_short(current_head)}; restart the server to load the "
|
||||
f"current capability gates")
|
||||
return _result(False, True, True, startup_head, current_head, reasons)
|
||||
live_known = live_remote_head is not None
|
||||
live_stale = live_known and live_remote_head != startup_head
|
||||
if live_stale:
|
||||
reasons.append(
|
||||
f"live remote master is {_short(live_remote_head)} but the MCP "
|
||||
f"server started at {_short(startup_head)}; the daemon is stale "
|
||||
f"relative to live master -- restart/reconnect before mutating")
|
||||
|
||||
return _result(
|
||||
local_in_parity, local_stale, True, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons):
|
||||
live_known = live_remote_head is not None
|
||||
mutation_safe = (
|
||||
determinable and in_parity and live_known and not live_stale)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"restart_required": stale or live_stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
# #610 distinguished signals:
|
||||
"daemon_start_head": startup_head,
|
||||
"local_head": current_head,
|
||||
"live_remote_head": live_remote_head,
|
||||
"live_known": live_known,
|
||||
"live_stale": live_stale,
|
||||
"mutation_safe": mutation_safe,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
@@ -130,11 +281,13 @@ def parity_block_reasons(assessment: dict) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||
|
||||
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||
reasons; only a definitively stale server blocks.
|
||||
reasons. A definitively stale server blocks, and (#610) a daemon that is
|
||||
stale relative to the *live* remote master blocks even when the local
|
||||
checkout HEAD still matches the daemon's startup commit.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
if assessment.get("stale") or assessment.get("live_stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
@@ -147,6 +300,10 @@ def parity_report(assessment: dict) -> dict:
|
||||
"restart_required": True,
|
||||
"startup_head": assessment.get("startup_head"),
|
||||
"current_head": assessment.get("current_head"),
|
||||
# #610: name the live remote target so the report distinguishes a
|
||||
# local-code stale from a daemon-behind-live-master stale.
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"live_stale": bool(assessment.get("live_stale")),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"recovery": [
|
||||
"The running MCP server is executing code older than the current "
|
||||
@@ -157,6 +314,45 @@ def parity_report(assessment: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def parity_resolver_disagreement(
|
||||
assessment: dict,
|
||||
resolver_restart_required: bool,
|
||||
) -> dict | None:
|
||||
"""Typed blocker when the resolver requires restart but parity looks green.
|
||||
|
||||
The capability resolver (``gitea_resolve_task_capability``) detects stale
|
||||
runtime authoritatively for mutation safety (#610). When it requires a
|
||||
restart, local-only parity must never override it: this returns a typed,
|
||||
fail-closed blocker that names the resolver as authoritative. Returns
|
||||
``None`` when the resolver does not require a restart.
|
||||
"""
|
||||
if not resolver_restart_required:
|
||||
return None
|
||||
parity_optimistic = bool(assessment.get("in_parity")) and not (
|
||||
assessment.get("stale") or assessment.get("live_stale"))
|
||||
return {
|
||||
"kind": "parity_resolver_disagreement",
|
||||
"restart_required": True,
|
||||
"resolver_authoritative": True,
|
||||
"parity_optimistic": parity_optimistic,
|
||||
"daemon_start_head": assessment.get("daemon_start_head"),
|
||||
"local_head": assessment.get("local_head"),
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"reasons": [
|
||||
"The capability resolver requires a restart/reconnect (stale "
|
||||
"runtime) but master-parity reported local code as in-parity. "
|
||||
"The resolver is authoritative for mutation safety; do not mutate "
|
||||
"on local parity alone. Restart/reconnect the Gitea MCP server "
|
||||
"and re-verify before mutating.",
|
||||
],
|
||||
"recovery": [
|
||||
"Trust the resolver: treat this session as stale.",
|
||||
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
|
||||
"current master and live target state, then re-run preflight.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_parity(assessment: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
if assessment.get("stale"):
|
||||
|
||||
@@ -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 <pid>` 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}"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,12 @@ ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
# Controller has no task worktree env — it routes only (#840).
|
||||
KNOWN_ROLE_KINDS = frozenset(
|
||||
{"author", "reviewer", "merger", "reconciler", "controller"}
|
||||
)
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler", "controller"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
@@ -37,8 +42,12 @@ def normalize_role_kind(
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if "controller" in profile or role == "controller":
|
||||
return "controller"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
if role in KNOWN_ROLE_KINDS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
@@ -80,7 +89,7 @@ def resolve_namespace_workspace(
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
role_env_key = ROLE_WORKTREE_ENVS.get(role)
|
||||
|
||||
# #618: durable author resolution — no silent control/master fallback.
|
||||
if role == "author" and verify_paths:
|
||||
@@ -108,13 +117,17 @@ def resolve_namespace_workspace(
|
||||
)
|
||||
return workspace, source
|
||||
|
||||
role_env_candidate = (
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable", True)
|
||||
if role_env_key
|
||||
else (None, "no role worktree env", True)
|
||||
)
|
||||
for candidate, source, env_sourced in (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(_env_value(env_map, role_env_key),
|
||||
f"{role_env_key} environment variable", True),
|
||||
role_env_candidate,
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree", False),
|
||||
# Author lock derivation is handled by the durable path above when
|
||||
@@ -433,7 +446,8 @@ def assess_namespace_mutation_workspace(
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
||||
f"{ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
"""Post-restart MCP reconciliation and completion proof (#662).
|
||||
|
||||
After an MCP process restart, sessions, leases, capabilities, worktrees, and
|
||||
interrupted mutations are not systematically reconciled; operators rebuild
|
||||
context from chat. This module is the pure classification core of the
|
||||
post-restart reconcile path.
|
||||
|
||||
Design rules (mirrors ``restart_coordinator`` / ``workflow_dashboard``):
|
||||
|
||||
* **Pure classification.** :func:`reconcile_after_restart` takes an already
|
||||
gathered inventory and returns a structured *completion proof*. It never
|
||||
touches the network, the filesystem, or a live process, so multi-session
|
||||
fixtures can drive every branch in unit tests.
|
||||
* **Fail closed.** Incomplete inventory never reports overall ``complete``.
|
||||
Ambiguous interrupted mutations are ``unresolved`` (never silently resumed).
|
||||
* **No blind write resume.** The proof never authorizes replaying a mutation;
|
||||
it only classifies evidence and names follow-up work.
|
||||
* **#660 soft dependency.** When durable session checkpoints are not present
|
||||
in the inventory, the checkpoint dimension is ``skipped`` with an explicit
|
||||
reason rather than inventing a schema (#660 lands separately).
|
||||
* **Log-only then enforce.** Default mode is ``log_only``. ``enforce`` sets
|
||||
``mutation_hold`` when anything remains unresolved so callers can block
|
||||
write ops until reconcile is complete or degraded mode is documented.
|
||||
|
||||
The single sanctioned gather+classify entry point is the MCP tool
|
||||
``gitea_reconcile_after_restart`` (read-only inventory gather + pure classify).
|
||||
Creating durable follow-up Gitea issues from unresolved items is an explicit
|
||||
apply step outside this pure module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
import lease_lifecycle
|
||||
|
||||
RECONCILE_VERSION = "1.0.0-issue-662"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Overall proof statuses.
|
||||
STATUS_COMPLETE = "complete"
|
||||
STATUS_DEGRADED = "degraded"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
# Per-dimension item statuses.
|
||||
ITEM_RESOLVED = "resolved"
|
||||
ITEM_UNRESOLVED = "unresolved"
|
||||
ITEM_DEGRADED = "degraded"
|
||||
ITEM_SKIPPED = "skipped"
|
||||
|
||||
# Modes.
|
||||
MODE_LOG_ONLY = "log_only"
|
||||
MODE_ENFORCE = "enforce"
|
||||
|
||||
# Lease / session phases that imply a write critical section was in flight.
|
||||
MUTATING_PHASES = frozenset(
|
||||
{
|
||||
"implementing",
|
||||
"publishing",
|
||||
"merging",
|
||||
"reviewing",
|
||||
"committing",
|
||||
"pushing",
|
||||
"closing",
|
||||
"mutating",
|
||||
"critical_section",
|
||||
}
|
||||
)
|
||||
|
||||
# Dimensions the acceptance criteria require.
|
||||
DIM_SERVICE_HEALTH = "service_health"
|
||||
DIM_CLIENTS = "clients"
|
||||
DIM_SESSIONS = "sessions"
|
||||
DIM_CHECKPOINTS = "checkpoints"
|
||||
DIM_LEASES = "leases"
|
||||
DIM_CAPABILITIES = "capabilities"
|
||||
DIM_WORKTREES = "worktrees"
|
||||
DIM_MUTATIONS = "interrupted_mutations"
|
||||
DIM_DUPLICATES = "duplicates"
|
||||
DIM_QUEUE = "queue"
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _ts(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileItem:
|
||||
"""One dimension of the post-restart reconcile report."""
|
||||
|
||||
dimension: str
|
||||
status: str
|
||||
summary: str
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
follow_up_required: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"dimension": self.dimension,
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"details": dict(self.details),
|
||||
"follow_up_required": self.follow_up_required,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FollowUpIssue:
|
||||
"""A durable follow-up issue the apply path may create for unresolved work."""
|
||||
|
||||
title: str
|
||||
body: str
|
||||
dimension: str
|
||||
severity: str = "high"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"dimension": self.dimension,
|
||||
"severity": self.severity,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestartCompletionProof:
|
||||
"""Machine-readable post-restart completion proof (#662 AC2)."""
|
||||
|
||||
schema_version: int
|
||||
reconcile_version: str
|
||||
reconcile_id: str
|
||||
started_at: str
|
||||
finished_at: str
|
||||
boot_head_sha: str | None
|
||||
current_head_sha: str | None
|
||||
inventory_complete: bool
|
||||
incomplete_reasons: tuple[str, ...]
|
||||
mode: str
|
||||
mutation_hold: bool
|
||||
overall_status: str
|
||||
items: tuple[ReconcileItem, ...]
|
||||
proposed_follow_ups: tuple[FollowUpIssue, ...]
|
||||
resolved_count: int
|
||||
unresolved_count: int
|
||||
skipped_count: int
|
||||
note: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"reconcile_version": self.reconcile_version,
|
||||
"reconcile_id": self.reconcile_id,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"boot_head_sha": self.boot_head_sha,
|
||||
"current_head_sha": self.current_head_sha,
|
||||
"inventory_complete": self.inventory_complete,
|
||||
"incomplete_reasons": list(self.incomplete_reasons),
|
||||
"mode": self.mode,
|
||||
"mutation_hold": self.mutation_hold,
|
||||
"overall_status": self.overall_status,
|
||||
"items": [i.as_dict() for i in self.items],
|
||||
"proposed_follow_ups": [f.as_dict() for f in self.proposed_follow_ups],
|
||||
"resolved_count": self.resolved_count,
|
||||
"unresolved_count": self.unresolved_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"note": self.note,
|
||||
"links": {
|
||||
"umbrella": 655,
|
||||
"vision": 652,
|
||||
"roadmap": 653,
|
||||
"issue": 662,
|
||||
"checkpoint_schema": 660,
|
||||
"drain_proof": 661,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _item(
|
||||
dimension: str,
|
||||
status: str,
|
||||
summary: str,
|
||||
*,
|
||||
details: dict[str, Any] | None = None,
|
||||
follow_up: bool = False,
|
||||
) -> ReconcileItem:
|
||||
return ReconcileItem(
|
||||
dimension=dimension,
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=dict(details or {}),
|
||||
follow_up_required=follow_up,
|
||||
)
|
||||
|
||||
|
||||
def _lease_freshness(lease: Mapping[str, Any]) -> str:
|
||||
fr = lease.get("freshness")
|
||||
if isinstance(fr, Mapping):
|
||||
return str(fr.get("freshness") or fr.get("status") or "unknown")
|
||||
if isinstance(fr, str):
|
||||
return fr
|
||||
# Fall back to pure classifier when raw lease rows are supplied.
|
||||
try:
|
||||
return str(lease_lifecycle.classify_lease_freshness(dict(lease)).get("freshness") or "unknown")
|
||||
except Exception: # noqa: BLE001 - pure path must not raise on bad rows
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _is_live_freshness(freshness: str) -> bool:
|
||||
return freshness in {"active", "live", "fresh"}
|
||||
|
||||
|
||||
def _is_mutating_phase(phase: str | None) -> bool:
|
||||
p = (phase or "").strip().lower()
|
||||
if not p:
|
||||
return False
|
||||
if p in MUTATING_PHASES:
|
||||
return True
|
||||
# Soft match for compound phases like "author_implementing".
|
||||
return any(token in p for token in MUTATING_PHASES)
|
||||
|
||||
|
||||
def _detect_interrupted_mutations(
|
||||
leases: Sequence[Mapping[str, Any]],
|
||||
pending_mutations: Sequence[Mapping[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return interrupted-mutation evidence (never auto-resumes writes)."""
|
||||
found: list[dict[str, Any]] = []
|
||||
|
||||
for raw in pending_mutations or ():
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"source": "pending_mutation_inventory",
|
||||
"status": "unresolved",
|
||||
"phase": raw.get("phase"),
|
||||
"session_id": raw.get("session_id"),
|
||||
"work_kind": raw.get("work_kind") or raw.get("kind"),
|
||||
"work_number": raw.get("work_number") or raw.get("number"),
|
||||
"reason": raw.get("reason")
|
||||
or "pending mutation recorded across process restart",
|
||||
"resume_allowed": False,
|
||||
}
|
||||
)
|
||||
|
||||
for lease in leases or ():
|
||||
if not isinstance(lease, Mapping):
|
||||
continue
|
||||
phase = lease.get("phase")
|
||||
freshness = _lease_freshness(lease)
|
||||
if not _is_mutating_phase(str(phase) if phase is not None else None):
|
||||
continue
|
||||
# A mutating phase whose owner is not live is interrupted.
|
||||
if _is_live_freshness(freshness):
|
||||
# Still live after restart is itself surprising — flag for review.
|
||||
found.append(
|
||||
{
|
||||
"source": "lease_mutating_phase",
|
||||
"status": "unresolved",
|
||||
"phase": phase,
|
||||
"freshness": freshness,
|
||||
"lease_id": lease.get("lease_id"),
|
||||
"session_id": lease.get("session_id"),
|
||||
"work_kind": lease.get("work_kind"),
|
||||
"work_number": lease.get("work_number"),
|
||||
"worktree_path": lease.get("worktree_path"),
|
||||
"reason": (
|
||||
"mutating lease phase still classified live after restart; "
|
||||
"do not auto-resume writes"
|
||||
),
|
||||
"resume_allowed": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
found.append(
|
||||
{
|
||||
"source": "lease_mutating_phase",
|
||||
"status": "unresolved",
|
||||
"phase": phase,
|
||||
"freshness": freshness,
|
||||
"lease_id": lease.get("lease_id"),
|
||||
"session_id": lease.get("session_id"),
|
||||
"work_kind": lease.get("work_kind"),
|
||||
"work_number": lease.get("work_number"),
|
||||
"worktree_path": lease.get("worktree_path"),
|
||||
"reason": (
|
||||
f"mutating lease phase '{phase}' with non-live freshness "
|
||||
f"'{freshness}' — interrupted by restart"
|
||||
),
|
||||
"resume_allowed": False,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _detect_duplicate_work(
|
||||
leases: Sequence[Mapping[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Surface duplicate live claims on the same work item."""
|
||||
by_work: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {}
|
||||
for lease in leases or ():
|
||||
if not isinstance(lease, Mapping):
|
||||
continue
|
||||
if not _is_live_freshness(_lease_freshness(lease)):
|
||||
continue
|
||||
key = (lease.get("work_kind"), lease.get("work_number"))
|
||||
if key[0] is None or key[1] is None:
|
||||
continue
|
||||
by_work.setdefault(key, []).append(lease)
|
||||
|
||||
dups: list[dict[str, Any]] = []
|
||||
for (kind, number), rows in sorted(by_work.items(), key=lambda kv: str(kv[0])):
|
||||
if len(rows) < 2:
|
||||
continue
|
||||
dups.append(
|
||||
{
|
||||
"work_kind": kind,
|
||||
"work_number": number,
|
||||
"claim_count": len(rows),
|
||||
"session_ids": [r.get("session_id") for r in rows],
|
||||
"lease_ids": [r.get("lease_id") for r in rows],
|
||||
}
|
||||
)
|
||||
return dups
|
||||
|
||||
|
||||
def _follow_up_for_item(item: ReconcileItem) -> FollowUpIssue | None:
|
||||
if not item.follow_up_required:
|
||||
return None
|
||||
title = f"[post-restart] unresolved {item.dimension} after MCP restart"
|
||||
body = (
|
||||
f"## Post-restart reconcile follow-up (#662)\n\n"
|
||||
f"**Dimension:** `{item.dimension}`\n"
|
||||
f"**Status:** `{item.status}`\n"
|
||||
f"**Summary:** {item.summary}\n\n"
|
||||
f"```json\n{item.details!r}\n```\n\n"
|
||||
f"Parent umbrella: #655 · Vision: #652 · Roadmap: #653 · Reconcile: #662\n"
|
||||
f"Do **not** auto-resume write mutations; reconcile evidence first.\n"
|
||||
)
|
||||
return FollowUpIssue(
|
||||
title=title,
|
||||
body=body,
|
||||
dimension=item.dimension,
|
||||
severity="high" if item.dimension == DIM_MUTATIONS else "medium",
|
||||
)
|
||||
|
||||
|
||||
def reconcile_after_restart(
|
||||
inventory: Mapping[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
mode: str = MODE_LOG_ONLY,
|
||||
reconcile_id: str | None = None,
|
||||
) -> RestartCompletionProof:
|
||||
"""Classify a post-restart inventory into a completion proof (#662).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inventory:
|
||||
Gathered facts. Expected keys (all optional except completeness):
|
||||
|
||||
* ``inventory_complete`` (bool) — fail closed when false
|
||||
* ``incomplete_reasons`` (list[str])
|
||||
* ``service_health`` (dict with ``healthy`` bool)
|
||||
* ``clients`` (list) — connected client descriptors
|
||||
* ``sessions`` (list)
|
||||
* ``leases`` (list, optionally with ``freshness``)
|
||||
* ``checkpoints`` (list | None) — durable session checkpoints (#660)
|
||||
* ``checkpoints_available`` (bool) — False when #660 schema absent
|
||||
* ``worktree_bindings`` (list)
|
||||
* ``pending_mutations`` (list) — explicit interrupted-mutation evidence
|
||||
* ``capabilities`` (dict with optional ``stale`` / heads)
|
||||
* ``boot_head_sha`` / ``current_head_sha``
|
||||
* ``queue_state`` (dict)
|
||||
mode:
|
||||
``log_only`` (default) or ``enforce`` (sets mutation_hold on unresolved).
|
||||
"""
|
||||
started = now or _utc_now()
|
||||
mode_norm = (mode or MODE_LOG_ONLY).strip().lower()
|
||||
if mode_norm not in {MODE_LOG_ONLY, MODE_ENFORCE}:
|
||||
mode_norm = MODE_LOG_ONLY
|
||||
|
||||
inventory_complete = bool(inventory.get("inventory_complete", False))
|
||||
incomplete_reasons = tuple(
|
||||
str(r) for r in (inventory.get("incomplete_reasons") or []) if str(r).strip()
|
||||
)
|
||||
|
||||
items: list[ReconcileItem] = []
|
||||
|
||||
# --- service health -------------------------------------------------
|
||||
health = inventory.get("service_health") or {}
|
||||
if not isinstance(health, Mapping):
|
||||
health = {}
|
||||
if not inventory_complete and "service_health" not in inventory:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SERVICE_HEALTH,
|
||||
ITEM_UNRESOLVED,
|
||||
"service health unknown because inventory is incomplete",
|
||||
details={"inventory_complete": False},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
elif health.get("healthy") is True:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SERVICE_HEALTH,
|
||||
ITEM_RESOLVED,
|
||||
"service health verified",
|
||||
details=dict(health),
|
||||
)
|
||||
)
|
||||
elif health.get("healthy") is False:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SERVICE_HEALTH,
|
||||
ITEM_UNRESOLVED,
|
||||
"service health check failed",
|
||||
details=dict(health),
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SERVICE_HEALTH,
|
||||
ITEM_DEGRADED,
|
||||
"service health not reported; treating as degraded",
|
||||
details=dict(health),
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
|
||||
# --- clients --------------------------------------------------------
|
||||
clients = list(inventory.get("clients") or [])
|
||||
disconnected = [
|
||||
c
|
||||
for c in clients
|
||||
if isinstance(c, Mapping) and c.get("connected") is False
|
||||
]
|
||||
if "clients" not in inventory:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CLIENTS,
|
||||
ITEM_SKIPPED,
|
||||
"client inventory not supplied",
|
||||
details={},
|
||||
)
|
||||
)
|
||||
elif disconnected:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CLIENTS,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(disconnected)} disconnected client(s) need reconnect",
|
||||
details={"disconnected": disconnected, "total": len(clients)},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CLIENTS,
|
||||
ITEM_RESOLVED,
|
||||
f"{len(clients)} client(s) accounted for",
|
||||
details={"total": len(clients)},
|
||||
)
|
||||
)
|
||||
|
||||
# --- sessions -------------------------------------------------------
|
||||
sessions = [s for s in (inventory.get("sessions") or []) if isinstance(s, Mapping)]
|
||||
orphan_sessions = [
|
||||
s
|
||||
for s in sessions
|
||||
if str(s.get("status") or "").lower() == "active"
|
||||
and s.get("pid") is not None
|
||||
and not lease_lifecycle.is_process_alive(s.get("pid"))
|
||||
]
|
||||
if orphan_sessions:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SESSIONS,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(orphan_sessions)} active session row(s) with dead owner pid",
|
||||
details={
|
||||
"orphan_session_ids": [s.get("session_id") for s in orphan_sessions],
|
||||
"total_sessions": len(sessions),
|
||||
},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_SESSIONS,
|
||||
ITEM_RESOLVED,
|
||||
f"{len(sessions)} session row(s) reconciled (no dead-pid orphans)",
|
||||
details={"total_sessions": len(sessions)},
|
||||
)
|
||||
)
|
||||
|
||||
# --- checkpoints (#660 soft) ----------------------------------------
|
||||
checkpoints_available = inventory.get("checkpoints_available")
|
||||
checkpoints = inventory.get("checkpoints")
|
||||
if checkpoints_available is False or (
|
||||
checkpoints is None and "checkpoints" not in inventory
|
||||
):
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CHECKPOINTS,
|
||||
ITEM_SKIPPED,
|
||||
"durable session checkpoint schema not available yet (#660)",
|
||||
details={"depends_on": 660},
|
||||
)
|
||||
)
|
||||
else:
|
||||
cp_list = [c for c in (checkpoints or []) if isinstance(c, Mapping)]
|
||||
stale_cp = [c for c in cp_list if c.get("stale") or c.get("invalid")]
|
||||
if stale_cp:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CHECKPOINTS,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(stale_cp)} checkpoint(s) invalid or stale vs live state",
|
||||
details={"stale_count": len(stale_cp), "total": len(cp_list)},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CHECKPOINTS,
|
||||
ITEM_RESOLVED,
|
||||
f"{len(cp_list)} checkpoint(s) consistent with live state",
|
||||
details={"total": len(cp_list)},
|
||||
)
|
||||
)
|
||||
|
||||
# --- leases / locks -------------------------------------------------
|
||||
leases = [L for L in (inventory.get("leases") or []) if isinstance(L, Mapping)]
|
||||
live_leases = [L for L in leases if _is_live_freshness(_lease_freshness(L))]
|
||||
items.append(
|
||||
_item(
|
||||
DIM_LEASES,
|
||||
ITEM_RESOLVED if inventory_complete else ITEM_DEGRADED,
|
||||
f"{len(live_leases)} live lease(s) of {len(leases)} inventoried",
|
||||
details={
|
||||
"live_count": len(live_leases),
|
||||
"total": len(leases),
|
||||
"live_lease_ids": [L.get("lease_id") for L in live_leases],
|
||||
},
|
||||
follow_up=not inventory_complete,
|
||||
)
|
||||
)
|
||||
|
||||
# --- capabilities / stale runtime -----------------------------------
|
||||
caps = inventory.get("capabilities") or {}
|
||||
if not isinstance(caps, Mapping):
|
||||
caps = {}
|
||||
if caps.get("stale") is True:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CAPABILITIES,
|
||||
ITEM_UNRESOLVED,
|
||||
"runtime code is stale vs on-disk master; restart did not reach parity",
|
||||
details=dict(caps),
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_CAPABILITIES,
|
||||
ITEM_RESOLVED,
|
||||
"capability/runtime parity acceptable",
|
||||
details=dict(caps) if caps else {"stale": False},
|
||||
)
|
||||
)
|
||||
|
||||
# --- worktrees ------------------------------------------------------
|
||||
bindings = [
|
||||
b for b in (inventory.get("worktree_bindings") or []) if isinstance(b, Mapping)
|
||||
]
|
||||
missing_wt = [
|
||||
b
|
||||
for b in bindings
|
||||
if b.get("missing") is True or b.get("exists") is False
|
||||
]
|
||||
if "worktree_bindings" not in inventory:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_WORKTREES,
|
||||
ITEM_SKIPPED,
|
||||
"worktree binding inventory not supplied",
|
||||
)
|
||||
)
|
||||
elif missing_wt:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_WORKTREES,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(missing_wt)} worktree binding(s) missing on disk",
|
||||
details={"missing": missing_wt, "total": len(bindings)},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_WORKTREES,
|
||||
ITEM_RESOLVED,
|
||||
f"{len(bindings)} worktree binding(s) present",
|
||||
details={"total": len(bindings)},
|
||||
)
|
||||
)
|
||||
|
||||
# --- interrupted mutations (AC4) ------------------------------------
|
||||
pending = [
|
||||
m
|
||||
for m in (inventory.get("pending_mutations") or [])
|
||||
if isinstance(m, Mapping)
|
||||
]
|
||||
interrupted = _detect_interrupted_mutations(leases, pending)
|
||||
if interrupted:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_MUTATIONS,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(interrupted)} interrupted mutation(s); write resume forbidden",
|
||||
details={"interrupted": interrupted},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_MUTATIONS,
|
||||
ITEM_RESOLVED,
|
||||
"no interrupted mutations detected",
|
||||
details={"interrupted": []},
|
||||
)
|
||||
)
|
||||
|
||||
# --- duplicates -----------------------------------------------------
|
||||
dups = _detect_duplicate_work(leases)
|
||||
if dups:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_DUPLICATES,
|
||||
ITEM_UNRESOLVED,
|
||||
f"{len(dups)} work item(s) have multiple live claims",
|
||||
details={"duplicates": dups},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_DUPLICATES,
|
||||
ITEM_RESOLVED,
|
||||
"no duplicate live claims detected",
|
||||
details={"duplicates": []},
|
||||
)
|
||||
)
|
||||
|
||||
# --- queue ----------------------------------------------------------
|
||||
queue = inventory.get("queue_state")
|
||||
if queue is None:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_QUEUE,
|
||||
ITEM_SKIPPED,
|
||||
"allocator queue state not supplied",
|
||||
)
|
||||
)
|
||||
elif isinstance(queue, Mapping) and queue.get("safe_to_resume") is False:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_QUEUE,
|
||||
ITEM_UNRESOLVED,
|
||||
"allocator queue not safe to resume",
|
||||
details=dict(queue),
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
_item(
|
||||
DIM_QUEUE,
|
||||
ITEM_RESOLVED,
|
||||
"allocator queue state acceptable",
|
||||
details=dict(queue) if isinstance(queue, Mapping) else {},
|
||||
)
|
||||
)
|
||||
|
||||
# Incomplete inventory always degrades the whole proof.
|
||||
if not inventory_complete:
|
||||
# Ensure at least one follow-up names the incomplete inventory.
|
||||
items.append(
|
||||
_item(
|
||||
"inventory",
|
||||
ITEM_UNRESOLVED,
|
||||
"control-plane inventory incomplete; reconcile cannot claim success",
|
||||
details={"reasons": list(incomplete_reasons)},
|
||||
follow_up=True,
|
||||
)
|
||||
)
|
||||
|
||||
resolved = sum(1 for i in items if i.status == ITEM_RESOLVED)
|
||||
unresolved = sum(1 for i in items if i.status in {ITEM_UNRESOLVED, ITEM_DEGRADED})
|
||||
skipped = sum(1 for i in items if i.status == ITEM_SKIPPED)
|
||||
|
||||
if not inventory_complete or any(i.status == ITEM_UNRESOLVED for i in items):
|
||||
if any(i.status == ITEM_UNRESOLVED for i in items) and inventory_complete:
|
||||
overall = STATUS_DEGRADED
|
||||
elif not inventory_complete:
|
||||
overall = STATUS_FAILED
|
||||
else:
|
||||
overall = STATUS_DEGRADED
|
||||
elif any(i.status == ITEM_DEGRADED for i in items):
|
||||
overall = STATUS_DEGRADED
|
||||
else:
|
||||
overall = STATUS_COMPLETE
|
||||
|
||||
# Enforce mode holds mutations whenever anything is unresolved/failed.
|
||||
mutation_hold = False
|
||||
if mode_norm == MODE_ENFORCE and overall in {STATUS_DEGRADED, STATUS_FAILED}:
|
||||
mutation_hold = True
|
||||
if mode_norm == MODE_ENFORCE and any(
|
||||
i.dimension == DIM_MUTATIONS and i.status == ITEM_UNRESOLVED for i in items
|
||||
):
|
||||
mutation_hold = True
|
||||
|
||||
follow_ups = tuple(
|
||||
fu for i in items if (fu := _follow_up_for_item(i)) is not None
|
||||
)
|
||||
|
||||
finished = _utc_now() if now is None else now
|
||||
note = (
|
||||
"Read-only completion proof. Never auto-resumes write mutations. "
|
||||
"Unresolved items require durable follow-up before claiming clean restart. "
|
||||
f"Mode={mode_norm}."
|
||||
)
|
||||
|
||||
return RestartCompletionProof(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
reconcile_version=RECONCILE_VERSION,
|
||||
reconcile_id=(reconcile_id or f"reconcile-{uuid4().hex[:12]}"),
|
||||
started_at=_ts(started),
|
||||
finished_at=_ts(finished),
|
||||
boot_head_sha=(
|
||||
str(inventory.get("boot_head_sha")).strip()
|
||||
if inventory.get("boot_head_sha")
|
||||
else None
|
||||
),
|
||||
current_head_sha=(
|
||||
str(inventory.get("current_head_sha")).strip()
|
||||
if inventory.get("current_head_sha")
|
||||
else None
|
||||
),
|
||||
inventory_complete=inventory_complete,
|
||||
incomplete_reasons=incomplete_reasons,
|
||||
mode=mode_norm,
|
||||
mutation_hold=mutation_hold,
|
||||
overall_status=overall,
|
||||
items=tuple(items),
|
||||
proposed_follow_ups=follow_ups,
|
||||
resolved_count=resolved,
|
||||
unresolved_count=unresolved,
|
||||
skipped_count=skipped,
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def mutations_allowed(proof: RestartCompletionProof | Mapping[str, Any] | None) -> bool:
|
||||
"""Return whether write mutations may proceed under the given proof."""
|
||||
if proof is None:
|
||||
return True # no proof yet → caller decides; enforce path sets hold
|
||||
if isinstance(proof, RestartCompletionProof):
|
||||
return not proof.mutation_hold
|
||||
if isinstance(proof, Mapping):
|
||||
return not bool(proof.get("mutation_hold"))
|
||||
return True
|
||||
+51
-2
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ cryptography==49.0.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
# Starlette 1.3.x TestClient prefers httpx2; plain httpx remains for MCP/runtime (#682).
|
||||
httpx2==2.9.1
|
||||
httpx-sse==0.4.3
|
||||
idna==3.18
|
||||
iniconfig==2.3.0
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -73,6 +73,8 @@ AUTHOR_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
"create_pr",
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
@@ -81,6 +83,12 @@ AUTHOR_TASKS = frozenset({
|
||||
"reconcile_landed_pr",
|
||||
})
|
||||
|
||||
CONTROLLER_TASKS = frozenset({
|
||||
"process_work_queue",
|
||||
"process-work-queue",
|
||||
"cross_role_allocate",
|
||||
})
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"cleanup_merged_pr_branch",
|
||||
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||
@@ -132,6 +140,10 @@ TASK_REQUIRED_ROLE = {
|
||||
"reconcile_close_superseded_pr": "reconciler",
|
||||
"reconcile_close_satisfied_issue": "reconciler",
|
||||
"reconcile_create_followup_issue": "reconciler",
|
||||
# #840: controller-owned generic queue allocation / routing.
|
||||
"process_work_queue": "controller",
|
||||
"process-work-queue": "controller",
|
||||
"cross_role_allocate": "controller",
|
||||
}
|
||||
|
||||
WRONG_ROLE_REVIEWER_MSG = (
|
||||
@@ -147,6 +159,10 @@ WRONG_ROLE_MERGER_MSG = (
|
||||
"Wrong role/session for merger task. Launch merger MCP namespace."
|
||||
)
|
||||
|
||||
WRONG_ROLE_CONTROLLER_MSG = (
|
||||
"Wrong role/session for controller task. Launch controller MCP namespace."
|
||||
)
|
||||
|
||||
_session_last_route: dict | None = None
|
||||
|
||||
|
||||
@@ -281,6 +297,27 @@ def route_task_session(
|
||||
_record_route(result)
|
||||
return result
|
||||
|
||||
if required_role == "controller":
|
||||
result = {
|
||||
"task_type": task_type,
|
||||
"required_role": required_role,
|
||||
"active_role": active_role_kind,
|
||||
"active_profile": active_profile,
|
||||
"route_result": ROUTE_WRONG_ROLE,
|
||||
"downstream_allowed": False,
|
||||
"reasons": [
|
||||
WRONG_ROLE_CONTROLLER_MSG,
|
||||
"Controller tasks (process_work_queue / cross-role allocate) "
|
||||
"cannot run in author, reviewer, merger, or reconciler "
|
||||
"worker sessions.",
|
||||
],
|
||||
"message": WRONG_ROLE_CONTROLLER_MSG,
|
||||
"runtime_switching_supported": runtime_switching_supported,
|
||||
"profile_switch_blocked": not runtime_switching_supported,
|
||||
}
|
||||
_record_route(result)
|
||||
return result
|
||||
|
||||
if required_role == "author":
|
||||
route = ROUTE_TO_AUTHOR
|
||||
message = (
|
||||
|
||||
+142
-4
@@ -49,7 +49,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Iterator
|
||||
|
||||
# Single source of truth for both the redactor and the gated-mutation set: the
|
||||
# #671 guard already owns them, so the two contamination models can never drift
|
||||
@@ -86,8 +86,26 @@ REMEDIATION = (
|
||||
|
||||
# Split a compound command line into simple commands on shell separators so
|
||||
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
|
||||
# inspection half never reaches the kill classifier.
|
||||
_SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)")
|
||||
# inspection half never reaches the kill classifier. The background separator
|
||||
# ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was
|
||||
# a single segment whose command position held ``sleep``, so the kill was never
|
||||
# classified (#787).
|
||||
#
|
||||
# Splitting is *quote-aware*, and a regex alternation cannot express that, so
|
||||
# the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator
|
||||
# only separates where it is syntactically active: outside single and double
|
||||
# quotes, and not backslash-escaped. Without that, adding ``&`` made every
|
||||
# benign mention of the canonical kill string classify as a real kill — a commit
|
||||
# message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same
|
||||
# sentence, a ``grep`` for it — and a false contamination marker fails review,
|
||||
# merge, close and completion mutations closed until a reconciler clears it (PR
|
||||
# #789 review finding F1). Quote-awareness is not specific to ``&``: it also
|
||||
# retires the same false-positive class that ``;`` and ``|`` carried before #787.
|
||||
_SEPARATOR_CHARS = frozenset("|&;\n")
|
||||
|
||||
#: Two-character logical separators, consumed whole so ``&&`` and ``||`` are
|
||||
#: never split into single characters leaving a stray operator behind.
|
||||
_LOGICAL_SEPARATORS = ("&&", "||")
|
||||
|
||||
_KILL_VERBS = frozenset({"kill", "pkill", "killall"})
|
||||
|
||||
@@ -133,8 +151,128 @@ def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def _iter_active(text: str) -> Iterator[tuple[int, str]]:
|
||||
"""Yield ``(index, char)`` for every *syntactically active* character.
|
||||
|
||||
Active means outside single and double quotes and not backslash-escaped —
|
||||
the positions where a shell metacharacter actually carries its meaning.
|
||||
Quoted runs, the quote characters themselves, and escaped characters are
|
||||
skipped, so a separator written inside a commit message or a ``grep``
|
||||
pattern is literal text rather than syntax. A backslash escapes nothing
|
||||
inside single quotes, matching POSIX.
|
||||
|
||||
An unterminated quote swallows the rest of the line, exactly as it does for
|
||||
the shell — which would reject such a command as a syntax error rather than
|
||||
run its tail, so nothing executable hides behind it.
|
||||
"""
|
||||
quote: str | None = None
|
||||
index = 0
|
||||
end = len(text)
|
||||
while index < end:
|
||||
char = text[index]
|
||||
if quote == "'":
|
||||
if char == "'":
|
||||
quote = None
|
||||
index += 1
|
||||
elif quote == '"':
|
||||
if char == "\\" and index + 1 < end:
|
||||
index += 2
|
||||
else:
|
||||
if char == '"':
|
||||
quote = None
|
||||
index += 1
|
||||
elif char == "\\" and index + 1 < end:
|
||||
index += 2
|
||||
elif char in ("'", '"'):
|
||||
quote = char
|
||||
index += 1
|
||||
else:
|
||||
yield index, char
|
||||
index += 1
|
||||
|
||||
|
||||
def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool:
|
||||
"""Is the ``&``/``|`` at *index* part of a redirection, not a separator?
|
||||
|
||||
``2>&1`` and ``>&2`` put the character immediately after a redirection
|
||||
operator, and ``&>log`` immediately before one; in neither position does it
|
||||
separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']``
|
||||
(PR #789 review finding F3).
|
||||
"""
|
||||
previous = command[index - 1] if index else ""
|
||||
if previous in ("<", ">") and (index - 1) in active:
|
||||
return True
|
||||
return (
|
||||
command[index] == "&"
|
||||
and command[index + 1:index + 2] == ">"
|
||||
and (index + 1) in active
|
||||
)
|
||||
|
||||
|
||||
def _closes_leading_paren(body: str) -> bool:
|
||||
"""Does *body* end with the active ``)`` matching a stripped leading ``(``?"""
|
||||
if not body.endswith(")"):
|
||||
return False
|
||||
depth = 0
|
||||
for index, char in _iter_active(body):
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
if depth == 0:
|
||||
return index == len(body) - 1
|
||||
depth -= 1
|
||||
return False
|
||||
|
||||
|
||||
def _strip_subshell(segment: str) -> str:
|
||||
"""Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified.
|
||||
|
||||
The parentheses are shell syntax, not part of the simple command, so a
|
||||
wrapped kill otherwise put ``(pkill`` in command position and never
|
||||
reached the kill classifier (#787). Nested wrappers are unwrapped too.
|
||||
|
||||
A trailing ``)`` is removed only when it closes a leading ``(`` this call
|
||||
stripped. Removing one unconditionally mangled balanced command
|
||||
substitution — ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp``
|
||||
(PR #789 review finding F3). An unmatched leading ``(`` is still dropped on
|
||||
its own, because splitting a wrapped compound orphans the opening half.
|
||||
"""
|
||||
stripped = segment.strip()
|
||||
while stripped.startswith("("):
|
||||
body = stripped[1:].strip()
|
||||
if _closes_leading_paren(body):
|
||||
body = body[:-1].strip()
|
||||
stripped = body
|
||||
return stripped
|
||||
|
||||
|
||||
def _split_segments(command: str) -> list[str]:
|
||||
return [seg for seg in _SEGMENT_SPLIT_RE.split(command) if seg.strip()]
|
||||
"""Split *command* into simple commands on syntactically active separators."""
|
||||
active = frozenset(index for index, _ in _iter_active(command))
|
||||
segments: list[str] = []
|
||||
start = 0
|
||||
index = 0
|
||||
end = len(command)
|
||||
while index < end:
|
||||
char = command[index]
|
||||
if (
|
||||
char not in _SEPARATOR_CHARS
|
||||
or index not in active
|
||||
or (char in "&|" and _is_redirection(command, index, active))
|
||||
):
|
||||
index += 1
|
||||
continue
|
||||
width = (
|
||||
2
|
||||
if command[index:index + 2] in _LOGICAL_SEPARATORS
|
||||
and (index + 1) in active
|
||||
else 1
|
||||
)
|
||||
segments.append(command[start:index])
|
||||
index += width
|
||||
start = index
|
||||
segments.append(command[start:])
|
||||
return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg]
|
||||
|
||||
|
||||
def is_sanctioned_recovery(text: str | None) -> bool:
|
||||
|
||||
+10
-8
@@ -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]+-.+ ]] \
|
||||
|
||||
+109
-2
@@ -32,6 +32,35 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #790 Slice A: prove an owned author lease is still active. Strictly
|
||||
# narrower than lock_issue — it can only slide a lease this exact session
|
||||
# already owns, never acquire, take over, or revive one — so it gates on the
|
||||
# same authority rather than introducing an operation name that every
|
||||
# already-configured author profile would be missing.
|
||||
"heartbeat_issue_lock": {
|
||||
"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",
|
||||
},
|
||||
# #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",
|
||||
@@ -58,10 +87,26 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"bootstrap_author_issue_worktree": {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_bootstrap_author_issue_worktree": {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"push_branch": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
# #812 AC20: publish an already-committed, unpublished local head so
|
||||
# exact-owner lease renewal has an observable remote head to reason about.
|
||||
# Same authority as any other author push — deliberately not a new
|
||||
# operation name, so it cannot widen an already-configured author profile.
|
||||
"publish_unpublished_branch": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
"create_pr": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
@@ -87,6 +132,16 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
},
|
||||
# #662: post-restart reconcile is read-only inventory + pure classification.
|
||||
# Durable follow-up issue creation is a separate apply path (not this task).
|
||||
"reconcile_after_restart": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_reconcile_after_restart": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||
"assess_pr_sync_status": {
|
||||
@@ -301,8 +356,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
# #600: controller-owned allocator — any authenticated profile may call;
|
||||
# routing enforces role match to selected work. Uses control-plane DB (#613).
|
||||
# #600: workers and controller may call with gitea.read; role-scoped workers
|
||||
# pass role=author|reviewer|merger|reconciler. Cross-role routing is the
|
||||
# controller default (#840). The canonical generic queue *task type* is
|
||||
# process_work_queue (controller-only below).
|
||||
"allocate_next_work": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
@@ -311,6 +368,34 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
# #840: documented generic queue task — controller routes only.
|
||||
"process_work_queue": {
|
||||
"permission": "gitea.read",
|
||||
"role": "controller",
|
||||
},
|
||||
"process-work-queue": {
|
||||
"permission": "gitea.read",
|
||||
"role": "controller",
|
||||
},
|
||||
"cross_role_allocate": {
|
||||
"permission": "gitea.read",
|
||||
"role": "controller",
|
||||
},
|
||||
|
||||
# #642: sanctioned host-daemon lifecycle controls. Deliberately *not* a
|
||||
# ``gitea.*`` operation — restarting an MCP namespace is a host action, not
|
||||
# a Gitea API call, and no configured Gitea profile should be able to
|
||||
# satisfy it by accident. Authority comes from the console RBAC model plus
|
||||
# out-of-band operator authorization (#630); these entries exist so the
|
||||
# console cannot invent an authority the capability layer never declared.
|
||||
"restart_namespace": {
|
||||
"permission": "runtime.restart_namespace",
|
||||
"role": "controller",
|
||||
},
|
||||
"reload_namespace": {
|
||||
"permission": "runtime.reload_namespace",
|
||||
"role": "controller",
|
||||
},
|
||||
|
||||
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
|
||||
# ownership in the control-plane DB (not a separate Gitea write permission).
|
||||
@@ -444,6 +529,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
|
||||
# #651 console analytics ingest — control-plane DB write, not a Gitea API
|
||||
# call. Authority comes from console RBAC (operator+) plus phase gating;
|
||||
# permission string is a non-Gitea runtime capability so no Gitea profile
|
||||
# can satisfy it by accident.
|
||||
"record_analytics_usage": {
|
||||
"permission": "runtime.record_analytics_usage",
|
||||
"role": "author",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +548,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
# merger lease (#763).
|
||||
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
||||
("review_pr", "acquire_reviewer_pr_lease"),
|
||||
# #850: native author issue worktree bootstrap
|
||||
("work_issue", "bootstrap_author_issue_worktree"),
|
||||
("bootstrap_author_issue_worktree", "lock_issue"),
|
||||
# #860: dirty-orphan recovery and related work_issue transitions (master)
|
||||
("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"),
|
||||
})
|
||||
|
||||
|
||||
@@ -500,6 +603,9 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
"publish_unpublished_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
@@ -524,6 +630,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_set_issue_labels": "set_issue_labels",
|
||||
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||
"gitea_create_label": "create_label",
|
||||
"gitea_bootstrap_author_issue_worktree": "bootstrap_author_issue_worktree",
|
||||
"gitea_commit_files": "commit_files",
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,35 @@ def _reset_mutation_authority(monkeypatch):
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_live_remote_master_head():
|
||||
"""#610 / PR #788 F1/F2: keep live-remote parity reads offline in tests.
|
||||
|
||||
``read_remote_master_head`` would otherwise ``git ls-remote`` whenever
|
||||
``GITEA_TEST_LIVE_REMOTE_HEAD`` is unset. Feature worktrees under
|
||||
``branches/`` always differ from live master, so legacy suites that assert
|
||||
runtime-context ``safe_next_action`` flip to live_stale. Module-level
|
||||
hermetic mode survives ``patch.dict(os.environ, …, clear=True)``.
|
||||
Tests that exercise the real probe path call
|
||||
``master_parity_gate.set_hermetic_test_mode(False)`` and/or set
|
||||
``GITEA_TEST_ALLOW_LIVE_REMOTE_PROBE``.
|
||||
"""
|
||||
try:
|
||||
import master_parity_gate as _mpg
|
||||
|
||||
_mpg.set_hermetic_test_mode(True)
|
||||
except Exception:
|
||||
_mpg = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _mpg is not None:
|
||||
try:
|
||||
_mpg.set_hermetic_test_mode(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deterministic_workspace_remotes():
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Allocator epic / child-only container pre-rank exclusion (#844).
|
||||
|
||||
Covers:
|
||||
* Issue #631-shaped child-only epic is excluded before ranking.
|
||||
* Implementable child issues remain eligible and can be selected.
|
||||
* Ordinary issues that merely mention "epic" in title/body are not excluded.
|
||||
* Excluded containers never receive assignments or workflow leases.
|
||||
* Structured skip reason ``epic_or_child_only_container`` is reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from allocator_service import (
|
||||
OUTCOME_ASSIGNED,
|
||||
OUTCOME_PREVIEW,
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_epic_or_child_only_container,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
# Minimal body mirroring issue #631 authoritative scope language.
|
||||
_EPIC_631_BODY = """
|
||||
## Scope (umbrella)
|
||||
|
||||
This epic owns the **product roadmap and linkage** for the Web Console.
|
||||
Implementation is delivered via child issues only.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
* Do not implement product features in this epic issue itself.
|
||||
* No product feature implementation is claimed complete solely on this epic.
|
||||
"""
|
||||
|
||||
_CHILD_BODY = """
|
||||
## Problem
|
||||
|
||||
Operators need a workflow-event timeline model for Phase 1.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Timeline model API exists
|
||||
"""
|
||||
|
||||
|
||||
def _issue(
|
||||
number: int,
|
||||
*,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
labels: tuple[str, ...] = ("status:ready", "type:feature"),
|
||||
priority: int = 20,
|
||||
) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=labels,
|
||||
title=title or f"issue {number}",
|
||||
body=body,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
|
||||
class ClassifyEpicContainerTest(unittest.TestCase):
|
||||
def test_631_shaped_body_and_title_is_container(self) -> None:
|
||||
c = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIn("body_marker", detail or "")
|
||||
|
||||
def test_body_markers_without_epic_title(self) -> None:
|
||||
c = _issue(
|
||||
900,
|
||||
title="Control plane roadmap tracker",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
|
||||
def test_epic_label_alone_is_container(self) -> None:
|
||||
c = _issue(
|
||||
901,
|
||||
title="Roadmap linkage",
|
||||
body="Track children.",
|
||||
labels=("status:ready", "type:epic"),
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIn("type:epic", detail or "")
|
||||
|
||||
def test_title_epic_prefix_alone_not_container(self) -> None:
|
||||
"""Title-only 'Epic:' without body scope evidence stays eligible (#844)."""
|
||||
c = _issue(
|
||||
902,
|
||||
title="Epic: something mentioned only in title",
|
||||
body="Implement a concrete fix for the allocator skip list.",
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
self.assertIsNone(detail)
|
||||
|
||||
def test_incidental_epic_word_not_container(self) -> None:
|
||||
c = _issue(
|
||||
903,
|
||||
title="Document epic handoff conventions",
|
||||
body=(
|
||||
"Update the docs so implementable issues that mention an epic "
|
||||
"remain independently executable."
|
||||
),
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
def test_prs_never_classified(self) -> None:
|
||||
pr = WorkCandidate(
|
||||
kind="pr",
|
||||
number=10,
|
||||
state="open",
|
||||
title="Epic: fake",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
head_sha="a" * 40,
|
||||
priority=5,
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(pr)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
|
||||
class AllocateEpicContainerExclusionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _alloc(self, candidates, **kwargs):
|
||||
defaults = dict(
|
||||
session_id="sess-844",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
profile_name="prgs-author",
|
||||
username="jcwalker3",
|
||||
claims={},
|
||||
apply=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||
|
||||
def test_631_shaped_epic_excluded_child_selected(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertIn(631, skipped)
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
self.assertIn(SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[631]["reason"])
|
||||
|
||||
def test_container_cannot_receive_assignment_or_lease(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
res = self._alloc([epic], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
# Only container present → no safe work; never assigned_work.
|
||||
self.assertNotEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertIsNone(res.get("assignment"))
|
||||
self.assertIsNone(res.get("selected"))
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
# No lease row for the epic.
|
||||
leases = self.db.list_active_leases(
|
||||
remote=REMOTE, org=ORG, repo=REPO
|
||||
) if hasattr(self.db, "list_active_leases") else []
|
||||
# Prefer generic inventory if available.
|
||||
if not leases and hasattr(self.db, "list_leases"):
|
||||
leases = self.db.list_leases(remote=REMOTE, org=ORG, repo=REPO)
|
||||
for lease in leases or []:
|
||||
work_number = lease.get("work_number") if isinstance(lease, dict) else None
|
||||
self.assertNotEqual(work_number, 631)
|
||||
|
||||
def test_incidental_epic_title_remains_eligible(self) -> None:
|
||||
ordinary = _issue(
|
||||
700,
|
||||
title="Document epic handoff conventions",
|
||||
body="Write runbook text about epic vs child issues.",
|
||||
)
|
||||
res = self._alloc([ordinary], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
self.assertEqual(res["skipped"], [])
|
||||
|
||||
def test_apply_selects_child_not_epic(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
self.assertEqual(res["assignment"]["work_number"], 637)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,572 @@
|
||||
"""Executable acceptance tests for ARCH-01 Slice A (#822).
|
||||
|
||||
Each acceptance criterion (#822 §12) and named test (#822 §13) is exercised
|
||||
against a real SQLite database. The migration runs on a fresh DB in ``setUp``;
|
||||
the test-run output is the durable evidence the issue requires (§14).
|
||||
|
||||
Enforcement being proven:
|
||||
|
||||
* ``[TRUSTED-SERVICE]`` — the ``cp_*`` actor functions exist only on the
|
||||
trusted kernel connection; a raw connection cannot satisfy the triggers.
|
||||
* ``[SCHEMA]`` — fail-closed aborts, exact dominance set, NOT-NULL class,
|
||||
immutability, and the last-active-grant floor are enforced by
|
||||
CHECK/FK/trigger, verified here including raw-write bypass and concurrency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import arch01_platform as ap
|
||||
from arch01_platform import (
|
||||
ALREADY_INSTALLED,
|
||||
AUTHORIZATION_DENIED,
|
||||
CONCURRENT_INSTALLATION_LOST,
|
||||
DISTINGUISHED_ISSUER_ID,
|
||||
DOMINANCE_SET_MISMATCH,
|
||||
DOMINANCE_TUPLES,
|
||||
INSTALLED,
|
||||
INVALID_ACTOR_CONTEXT,
|
||||
INVALID_BOOTSTRAP_STATE,
|
||||
PlatformKernel,
|
||||
)
|
||||
|
||||
INSTALLER = "platform.installer"
|
||||
|
||||
_BOOTSTRAP_TABLES = (
|
||||
"principal_equivalence_classes",
|
||||
"principals",
|
||||
"authoritative_issuers",
|
||||
"authority_dominance",
|
||||
"platform_bootstrap_seed",
|
||||
"platform_bootstrap_grants",
|
||||
"platform_active_invariant",
|
||||
"install_state",
|
||||
)
|
||||
|
||||
|
||||
def _count(kernel: PlatformKernel, table: str) -> int:
|
||||
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
|
||||
|
||||
def _count_where(kernel: PlatformKernel, table: str, where: str) -> int:
|
||||
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
|
||||
|
||||
|
||||
def _all_bootstrap_empty(kernel: PlatformKernel) -> bool:
|
||||
return all(_count(kernel, t) == 0 for t in _BOOTSTRAP_TABLES)
|
||||
|
||||
|
||||
class Arch01MemoryTest(unittest.TestCase):
|
||||
"""Single-connection behavior on an in-memory database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.kernel = PlatformKernel(":memory:")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.kernel.close()
|
||||
|
||||
# -- AC1 -------------------------------------------------------------- #
|
||||
def test_install_clean(self) -> None: # t_install_clean(+)
|
||||
res = self.kernel.install_platform(INSTALLER)
|
||||
self.assertEqual(res.code, INSTALLED)
|
||||
self.assertTrue(self.kernel.is_installed())
|
||||
self.assertEqual(_count(self.kernel, "install_state"), 1)
|
||||
self.assertEqual(self.kernel.active_grant_count(), 1)
|
||||
self.assertIn(ap.EVT_PLATFORM_INSTALLED, self.kernel.audit_events())
|
||||
rows = set(
|
||||
self.kernel._conn.execute(
|
||||
"SELECT dominant, subordinate FROM authority_dominance"
|
||||
).fetchall()
|
||||
)
|
||||
self.assertEqual(rows, set(DOMINANCE_TUPLES))
|
||||
issuer_ref = self.kernel._conn.execute(
|
||||
"SELECT i.issuer_ref FROM principals p JOIN authoritative_issuers i "
|
||||
"ON p.issuer_id = i.issuer_id WHERE p.principal_id = ?",
|
||||
(INSTALLER,),
|
||||
).fetchone()
|
||||
self.assertEqual(issuer_ref[0], DISTINGUISHED_ISSUER_ID)
|
||||
|
||||
# -- AC2 -------------------------------------------------------------- #
|
||||
def test_install_twice(self) -> None: # t_install_twice(-)
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
res2 = self.kernel.install_platform(INSTALLER)
|
||||
self.assertEqual(res2.code, ALREADY_INSTALLED)
|
||||
self.assertEqual(_count(self.kernel, "principals"), 1)
|
||||
self.assertEqual(_count(self.kernel, "platform_bootstrap_grants"), 1)
|
||||
self.assertEqual(_count(self.kernel, "install_state"), 1)
|
||||
|
||||
# -- AC3 / AC5 -------------------------------------------------------- #
|
||||
def test_install_stage_rollback(self) -> None: # t_install_stage_rollback
|
||||
for stop in range(1, 9):
|
||||
with self.subTest(stages=stop):
|
||||
k = PlatformKernel(":memory:")
|
||||
try:
|
||||
self._partial_bootstrap_then_rollback(k, stop)
|
||||
self.assertTrue(
|
||||
_all_bootstrap_empty(k),
|
||||
f"partial rows survived rollback at stage {stop}",
|
||||
)
|
||||
self.assertFalse(k.is_installed())
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
def test_no_partial_after_rollback(self) -> None: # t_no_partial_after_rollback
|
||||
k = PlatformKernel(":memory:")
|
||||
try:
|
||||
code = self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1])
|
||||
self.assertEqual(code, DOMINANCE_SET_MISMATCH)
|
||||
self.assertTrue(_all_bootstrap_empty(k))
|
||||
self.assertFalse(k.is_installed())
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
# -- AC4 -------------------------------------------------------------- #
|
||||
def test_dominance_missing(self) -> None: # t_dominance_missing(-)
|
||||
k = PlatformKernel(":memory:")
|
||||
try:
|
||||
self.assertEqual(
|
||||
self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1]),
|
||||
DOMINANCE_SET_MISMATCH,
|
||||
)
|
||||
self.assertFalse(k.is_installed())
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
def test_dominance_extra(self) -> None: # t_dominance_extra(-)
|
||||
k = PlatformKernel(":memory:")
|
||||
try:
|
||||
extra = DOMINANCE_TUPLES + (("platform.bootstrap", "rogue.extra"),)
|
||||
self.assertEqual(
|
||||
self._seed_bootstrap_and_mark(k, dominance=extra),
|
||||
DOMINANCE_SET_MISMATCH,
|
||||
)
|
||||
self.assertFalse(k.is_installed())
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
def test_dominance_malformed(self) -> None: # t_dominance_malformed(-)
|
||||
k = PlatformKernel(":memory:")
|
||||
try:
|
||||
malformed = DOMINANCE_TUPLES[:-1] + (("supervisor.root", "WRONG.subordinate"),)
|
||||
self.assertEqual(
|
||||
self._seed_bootstrap_and_mark(k, dominance=malformed),
|
||||
DOMINANCE_SET_MISMATCH,
|
||||
)
|
||||
self.assertFalse(k.is_installed())
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
# -- AC6 -------------------------------------------------------------- #
|
||||
def test_principal_no_class(self) -> None: # t_principal_no_class(-)
|
||||
with self.kernel.actor_context("op", "operator", "install"):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principals"
|
||||
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES ('x', 'operator', NULL, NULL, NULL, '2026-01-01T00:00:00Z')"
|
||||
)
|
||||
|
||||
# -- AC7 -------------------------------------------------------------- #
|
||||
def test_noninstaller_null_issuer(self) -> None: # t_nonobstaller_null_issuer(-)
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
cur = self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
class_id = cur.lastrowid
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principals"
|
||||
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES ('rogue', 'operator', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
|
||||
(class_id,),
|
||||
)
|
||||
self.assertIn("INVALID_BOOTSTRAP_STATE", str(ctx.exception))
|
||||
|
||||
def test_installer_null_issuer_only_during_install(self) -> None:
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
with self.kernel.actor_context("i2", "installer", "install"):
|
||||
cur = self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
class_id = cur.lastrowid
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principals"
|
||||
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES ('i2', 'installer', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
|
||||
(class_id,),
|
||||
)
|
||||
|
||||
# -- AC8 -------------------------------------------------------------- #
|
||||
def test_context_missing(self) -> None: # t_context_missing(-)
|
||||
self.assertIsNone(self.kernel._ctx)
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||
|
||||
def test_context_stale(self) -> None: # t_context_stale(-)
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
self.kernel._ctx.expired = True
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||
|
||||
def test_context_epoch_shift(self) -> None: # t_context_epoch_shift(-)
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
self.kernel._ctx.live_epoch = self.kernel._ctx.bound_epoch + 99
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||
|
||||
def test_bad_actor_kind_or_mode_rejected(self) -> None:
|
||||
for kind, mode in (("intruder", "normal"), ("operator", "sabotage")):
|
||||
with self.subTest(kind=kind, mode=mode):
|
||||
with self.kernel.actor_context("op", kind, mode):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.kernel._conn.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) "
|
||||
"VALUES ('2026-01-01T00:00:00Z')"
|
||||
)
|
||||
|
||||
# -- AC9 -------------------------------------------------------------- #
|
||||
def test_bootstrap_immutable_update(self) -> None: # t_bootstrap_immutable_{update}
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
cases = [
|
||||
("UPDATE install_state SET installed_at = 'x' WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
|
||||
("UPDATE platform_bootstrap_seed SET created_at = 'x' WHERE seed_id = 1", "IMMUTABLE_SEED"),
|
||||
("UPDATE authority_dominance SET subordinate = 'x' WHERE dominant = 'supervisor.root'", "IMMUTABLE_DOMINANCE"),
|
||||
(f"UPDATE authoritative_issuers SET issuer_ref = 'x' WHERE issuer_ref = '{DISTINGUISHED_ISSUER_ID}'", "IMMUTABLE_ISSUER"),
|
||||
(f"UPDATE principals SET actor_kind = 'operator' WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
|
||||
]
|
||||
for sql, tag in cases:
|
||||
with self.subTest(sql=sql):
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(sql)
|
||||
self.assertIn(tag, str(ctx.exception))
|
||||
|
||||
def test_bootstrap_immutable_delete(self) -> None: # t_bootstrap_immutable_{delete}
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
cases = [
|
||||
("DELETE FROM install_state WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
|
||||
("DELETE FROM platform_bootstrap_seed WHERE seed_id = 1", "IMMUTABLE_SEED"),
|
||||
("DELETE FROM authority_dominance", "IMMUTABLE_DOMINANCE"),
|
||||
("DELETE FROM authoritative_issuers", "IMMUTABLE_ISSUER"),
|
||||
(f"DELETE FROM principals WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
|
||||
("DELETE FROM platform_bootstrap_grants", "IMMUTABLE_GRANT"),
|
||||
]
|
||||
for sql, tag in cases:
|
||||
with self.subTest(sql=sql):
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(sql)
|
||||
self.assertIn(tag, str(ctx.exception))
|
||||
|
||||
def test_grant_reactivation_rejected(self) -> None:
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
self.kernel.register_principal(
|
||||
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||
)
|
||||
self.assertEqual(
|
||||
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
|
||||
)
|
||||
gid = self.kernel._conn.execute(
|
||||
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(
|
||||
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
|
||||
INSTALLED,
|
||||
)
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||
self.kernel._conn.execute(
|
||||
"UPDATE platform_bootstrap_grants SET active = 1 WHERE grant_id = ?",
|
||||
(gid,),
|
||||
)
|
||||
self.assertIn("IMMUTABLE_GRANT", str(ctx.exception))
|
||||
|
||||
# -- AC12 ------------------------------------------------------------- #
|
||||
def test_raw_write_bypass(self) -> None: # t_raw_write_bypass(raw-bypass)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "p.sqlite3")
|
||||
k = PlatformKernel(path)
|
||||
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
|
||||
k.close()
|
||||
raw = sqlite3.connect(path)
|
||||
raw.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
raw.execute(
|
||||
"INSERT INTO audit_records(event, created_at) "
|
||||
"VALUES ('forged', '2026-01-01T00:00:00Z')"
|
||||
)
|
||||
raw.commit()
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
raw.execute("UPDATE install_state SET installed_at = 'x' WHERE id = 1")
|
||||
raw.commit()
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
raw.execute("DELETE FROM platform_bootstrap_grants")
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
# -- AC13 ------------------------------------------------------------- #
|
||||
def test_audit_created(self) -> None: # t_audit_created(+)
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
self.kernel.register_principal(
|
||||
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||
)
|
||||
self.assertEqual(
|
||||
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
|
||||
)
|
||||
gid = self.kernel._conn.execute(
|
||||
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(
|
||||
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
|
||||
INSTALLED,
|
||||
)
|
||||
events = self.kernel.audit_events()
|
||||
for evt in (
|
||||
ap.EVT_PLATFORM_INSTALLED,
|
||||
ap.EVT_GRANT_CREATED,
|
||||
ap.EVT_GRANT_REVOKED,
|
||||
ap.EVT_PRINCIPAL_REGISTERED,
|
||||
):
|
||||
self.assertIn(evt, events)
|
||||
|
||||
# -- AC14 ------------------------------------------------------------- #
|
||||
def test_audit_immutable(self) -> None: # t_audit_immutable(raw-bypass)
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
with self.kernel.actor_context("op", "operator", "normal"):
|
||||
with self.assertRaises(sqlite3.IntegrityError) as up:
|
||||
self.kernel._conn.execute("UPDATE audit_records SET event = 'x' WHERE audit_id = 1")
|
||||
self.assertIn("IMMUTABLE_AUDIT", str(up.exception))
|
||||
with self.assertRaises(sqlite3.IntegrityError) as dl:
|
||||
self.kernel._conn.execute("DELETE FROM audit_records WHERE audit_id = 1")
|
||||
self.assertIn("IMMUTABLE_AUDIT", str(dl.exception))
|
||||
|
||||
# -- meta ------------------------------------------------------------- #
|
||||
def test_schema_meta(self) -> None:
|
||||
rows = dict(self.kernel._conn.execute("SELECT key, value FROM arch01_meta").fetchall())
|
||||
self.assertEqual(rows["schema_version"], str(ap.SCHEMA_VERSION))
|
||||
self.assertIn("disabled by default", rows["architecture"])
|
||||
|
||||
def test_register_principal_creates_class_first(self) -> None:
|
||||
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||
res = self.kernel.register_principal(
|
||||
"svc1", "service", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||
)
|
||||
self.assertEqual(res.code, INSTALLED)
|
||||
row = self.kernel._conn.execute(
|
||||
"SELECT current_class_id FROM principals WHERE principal_id = 'svc1'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row[0])
|
||||
|
||||
# -- helpers ---------------------------------------------------------- #
|
||||
def _partial_bootstrap_then_rollback(self, k: PlatformKernel, stop: int) -> None:
|
||||
"""Execute the first ``stop`` bootstrap statements, then ROLLBACK."""
|
||||
now = "2026-01-01T00:00:00Z"
|
||||
k._conn.execute("BEGIN IMMEDIATE")
|
||||
class_id = None
|
||||
issuer_id = None
|
||||
try:
|
||||
with k.actor_context(INSTALLER, "installer", "install"):
|
||||
c = k._conn
|
||||
if stop >= 1:
|
||||
class_id = c.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
|
||||
).lastrowid
|
||||
if stop >= 2:
|
||||
c.execute(
|
||||
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||
(INSTALLER, class_id, INSTALLER, now),
|
||||
)
|
||||
if stop >= 3:
|
||||
issuer_id = c.execute(
|
||||
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
|
||||
(DISTINGUISHED_ISSUER_ID, now),
|
||||
).lastrowid
|
||||
if stop >= 4:
|
||||
c.execute(
|
||||
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||
(issuer_id, INSTALLER),
|
||||
)
|
||||
if stop >= 5:
|
||||
c.executemany(
|
||||
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||
DOMINANCE_TUPLES,
|
||||
)
|
||||
if stop >= 6:
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
|
||||
(INSTALLER, now),
|
||||
)
|
||||
if stop >= 7:
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
|
||||
(INSTALLER, now),
|
||||
)
|
||||
if stop >= 8:
|
||||
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
|
||||
finally:
|
||||
k._conn.execute("ROLLBACK")
|
||||
|
||||
def _seed_bootstrap_and_mark(self, k: PlatformKernel, dominance) -> str:
|
||||
"""Seed a full bootstrap with a caller-supplied dominance set, then
|
||||
attempt the marker insert. Returns the classified failure code (or
|
||||
INSTALLED). Rolls back on failure so no partial rows remain."""
|
||||
now = "2026-01-01T00:00:00Z"
|
||||
k._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
with k.actor_context(INSTALLER, "installer", "install"):
|
||||
c = k._conn
|
||||
class_id = c.execute(
|
||||
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
|
||||
).lastrowid
|
||||
c.execute(
|
||||
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||
(INSTALLER, class_id, INSTALLER, now),
|
||||
)
|
||||
issuer_id = c.execute(
|
||||
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
|
||||
(DISTINGUISHED_ISSUER_ID, now),
|
||||
).lastrowid
|
||||
c.execute(
|
||||
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||
(issuer_id, INSTALLER),
|
||||
)
|
||||
c.executemany(
|
||||
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||
dominance,
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
|
||||
(INSTALLER, now),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
|
||||
(INSTALLER, now),
|
||||
)
|
||||
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
|
||||
c.execute(
|
||||
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
|
||||
(now,),
|
||||
)
|
||||
k._conn.execute("COMMIT")
|
||||
return INSTALLED
|
||||
except sqlite3.Error as exc:
|
||||
k._safe_rollback()
|
||||
return PlatformKernel._classify(exc)
|
||||
|
||||
|
||||
class Arch01ConcurrencyTest(unittest.TestCase):
|
||||
"""Concurrency invariants require file-backed DBs and independent connections."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self._tmp.name, "p.sqlite3")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
# -- AC10 ------------------------------------------------------------- #
|
||||
def test_concurrent_install(self) -> None: # t_concurrent_install(concurrency)
|
||||
k1 = PlatformKernel(self.path, busy_timeout_ms=0)
|
||||
k2 = PlatformKernel(self.path, busy_timeout_ms=0)
|
||||
barrier = threading.Barrier(2)
|
||||
results = {}
|
||||
|
||||
def _install(name, kernel):
|
||||
barrier.wait()
|
||||
results[name] = kernel.install_platform(INSTALLER).code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
f1 = ex.submit(_install, "a", k1)
|
||||
f2 = ex.submit(_install, "b", k2)
|
||||
f1.result()
|
||||
f2.result()
|
||||
codes = sorted(results.values())
|
||||
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one install expected: {results}")
|
||||
other = [c for c in results.values() if c != INSTALLED][0]
|
||||
self.assertIn(other, (ALREADY_INSTALLED, CONCURRENT_INSTALLATION_LOST))
|
||||
self.assertTrue(k1.is_installed())
|
||||
self.assertEqual(_count(k1, "install_state"), 1)
|
||||
self.assertEqual(_count(k1, "principals"), 1)
|
||||
finally:
|
||||
k1.close()
|
||||
k2.close()
|
||||
|
||||
# -- AC11 ------------------------------------------------------------- #
|
||||
def test_concurrent_last_grant_revoke(self) -> None: # t_concurrent_last_grant_revoke
|
||||
setup = PlatformKernel(self.path)
|
||||
self.assertEqual(setup.install_platform(INSTALLER).code, INSTALLED)
|
||||
setup.register_principal("op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER)
|
||||
self.assertEqual(setup.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED)
|
||||
self.assertEqual(setup.active_grant_count(), 2)
|
||||
gids = [
|
||||
r[0]
|
||||
for r in setup._conn.execute(
|
||||
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1 ORDER BY grant_id"
|
||||
).fetchall()
|
||||
]
|
||||
setup.close()
|
||||
self.assertEqual(len(gids), 2)
|
||||
|
||||
k1 = PlatformKernel(self.path, busy_timeout_ms=3000)
|
||||
k2 = PlatformKernel(self.path, busy_timeout_ms=3000)
|
||||
barrier = threading.Barrier(2)
|
||||
results = {}
|
||||
|
||||
def _revoke(name, kernel, gid):
|
||||
barrier.wait()
|
||||
results[name] = kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
f1 = ex.submit(_revoke, "a", k1, gids[0])
|
||||
f2 = ex.submit(_revoke, "b", k2, gids[1])
|
||||
f1.result()
|
||||
f2.result()
|
||||
codes = list(results.values())
|
||||
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one revoke should win: {results}")
|
||||
self.assertEqual(codes.count(AUTHORIZATION_DENIED), 1, f"one revoke must be denied: {results}")
|
||||
self.assertEqual(k1.active_grant_count(), 1)
|
||||
self.assertEqual(_count_where(k1, "platform_bootstrap_grants", "active = 1"), 1)
|
||||
finally:
|
||||
k1.close()
|
||||
k2.close()
|
||||
|
||||
def test_revoke_final_grant_denied(self) -> None:
|
||||
k = PlatformKernel(self.path)
|
||||
try:
|
||||
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
|
||||
gid = k._conn.execute(
|
||||
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1"
|
||||
).fetchone()[0]
|
||||
res = k.revoke_platform_bootstrap(gid, actor_principal=INSTALLER)
|
||||
self.assertEqual(res.code, AUTHORIZATION_DENIED)
|
||||
self.assertEqual(k.active_grant_count(), 1)
|
||||
self.assertEqual(_count_where(k, "platform_bootstrap_grants", "active = 1"), 1)
|
||||
finally:
|
||||
k.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Regression test suite for native author issue worktree bootstrap (#850)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import author_issue_bootstrap
|
||||
import task_capability_map
|
||||
|
||||
|
||||
def _concurrent_bootstrap_worker(args: tuple[str, int, str, str, str, str]) -> dict:
|
||||
repo_dir, issue_num, key, lock_dir, journal_dir, master_sha = args
|
||||
os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"] = journal_dir
|
||||
return author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=issue_num,
|
||||
canonical_repo_root=repo_dir,
|
||||
expected_base_sha=master_sha,
|
||||
idempotency_key=key,
|
||||
lock_dir=lock_dir,
|
||||
owner_session="session-concurrent-test",
|
||||
active_identity="jcwalker3",
|
||||
active_profile="prgs-author",
|
||||
)
|
||||
|
||||
|
||||
class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
"""Test suite covering AC1-AC10 and comment #14959 specification."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp_dir = tempfile.mkdtemp(prefix="test_bootstrap_")
|
||||
self.repo_dir = os.path.join(self.tmp_dir, "repo")
|
||||
os.makedirs(self.repo_dir)
|
||||
|
||||
# Initialize synthetic git repo
|
||||
subprocess.run(["git", "init", "-b", "master"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.repo_dir, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.repo_dir, check=True)
|
||||
|
||||
readme = os.path.join(self.repo_dir, "README.md")
|
||||
with open(readme, "w", encoding="utf-8") as f:
|
||||
f.write("# Test Repo\n")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "initial commit"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
|
||||
rev_res = subprocess.run(["git", "rev-parse", "HEAD"], cwd=self.repo_dir, capture_output=True, text=True, check=True)
|
||||
self.master_sha = rev_res.stdout.strip()
|
||||
|
||||
self.branches_dir = os.path.join(self.repo_dir, "branches")
|
||||
os.makedirs(self.branches_dir, exist_ok=True)
|
||||
self.lock_dir = os.path.join(self.tmp_dir, "locks")
|
||||
os.makedirs(self.lock_dir, exist_ok=True)
|
||||
self.journal_dir = os.path.join(self.tmp_dir, "journals")
|
||||
os.makedirs(self.journal_dir, exist_ok=True)
|
||||
os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"] = self.journal_dir
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("GITEA_BOOTSTRAP_JOURNAL_DIR", None)
|
||||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_bootstrap_success_path(self):
|
||||
"""AC1/AC3/AC8: Successful bootstrap creates branch, worktree, registration, and lock proof."""
|
||||
key = "test_key_success_1"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
# assignment_id/lease_id omitted: optional unless verified live.
|
||||
expected_base_sha=self.master_sha,
|
||||
idempotency_key=key,
|
||||
remote="prgs",
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res.get("success"), f"Bootstrap failed: {res}")
|
||||
self.assertFalse(res.get("replayed"))
|
||||
self.assertEqual(res.get("issue_number"), 850)
|
||||
self.assertEqual(res.get("base_sha"), self.master_sha)
|
||||
self.assertIn("branches/fix-issue-850-native-mcp-bootstrap", res.get("worktree_path"))
|
||||
|
||||
# Verify worktree directory exists and is registered
|
||||
worktree_path = res["worktree_path"]
|
||||
self.assertTrue(os.path.isdir(worktree_path))
|
||||
|
||||
wt_list = subprocess.run(["git", "-C", self.repo_dir, "worktree", "list"], capture_output=True, text=True, check=True)
|
||||
self.assertIn(worktree_path, wt_list.stdout)
|
||||
|
||||
# Verify phase journal written
|
||||
journal = author_issue_bootstrap.load_phase_journal(key, journal_dir=self.lock_dir)
|
||||
self.assertIsNotNone(journal)
|
||||
self.assertTrue(journal.get("completed"))
|
||||
self.assertEqual(journal.get("current_phase"), author_issue_bootstrap.PHASE_7_TRANSITION_COMPLETED)
|
||||
|
||||
def test_idempotent_replay(self):
|
||||
"""Item 2: Replaying with identical key returns cached transition without duplicate creation."""
|
||||
key = "test_key_idempotent_1"
|
||||
res1 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"], f"res1 failed: {res1}")
|
||||
self.assertFalse(res1.get("replayed"))
|
||||
|
||||
# Second call
|
||||
res2 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res2["success"], f"res2 failed: {res2}")
|
||||
self.assertTrue(res2.get("replayed"))
|
||||
self.assertEqual(res1["worktree_path"], res2["worktree_path"])
|
||||
|
||||
def test_stale_concurrency_pin_refusal(self):
|
||||
"""Item 3: Mismatched expected base SHA fails closed without silent rebasing."""
|
||||
stale_sha = "0000000000000000000000000000000000000000"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "stale_concurrency_pin")
|
||||
self.assertIn("exact_next_action", res)
|
||||
|
||||
def test_path_outside_branches_root_refusal(self):
|
||||
"""Item 6: Worktree path outside branches/ root is refused."""
|
||||
outside_path = os.path.join(self.tmp_dir, "outside_worktree")
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
worktree_path=outside_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "path_outside_canonical_branches_root")
|
||||
|
||||
def test_preexisting_dirty_worktree_preservation(self):
|
||||
"""Item 6: Preexisting dirty worktree fails closed and is NOT modified or cleaned."""
|
||||
branch = "fix/issue-850-dirty-test"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-dirty-test")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", "-b", branch, wt_path], check=True, capture_output=True)
|
||||
|
||||
# Create dirty untracked file
|
||||
dirty_file = os.path.join(wt_path, "dirty.txt")
|
||||
with open(dirty_file, "w") as f:
|
||||
f.write("dirty edits\n")
|
||||
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=branch,
|
||||
worktree_path=wt_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "preexisting_dirty_worktree")
|
||||
|
||||
# Prove dirty file is preserved byte-for-byte
|
||||
self.assertTrue(os.path.exists(dirty_file))
|
||||
with open(dirty_file, "r") as f:
|
||||
self.assertEqual(f.read(), "dirty edits\n")
|
||||
|
||||
def test_compensating_recovery_on_failed_phase(self):
|
||||
"""AC4/Item 4: Failure during transition rolls back ONLY newly created artifacts."""
|
||||
key = "test_key_recovery_1"
|
||||
# Simulate partial progress in journal
|
||||
journal = {
|
||||
"idempotency_key": key,
|
||||
"issue_number": 850,
|
||||
"branch_name": "fix/issue-850-recovery-test",
|
||||
"worktree_path": os.path.join(self.branches_dir, "fix-issue-850-recovery-test"),
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
"lock_created": False,
|
||||
},
|
||||
"failure_reason": "simulated lock failure",
|
||||
"current_phase": author_issue_bootstrap.PHASE_5_REGISTRATION_VERIFIED,
|
||||
"completed": False,
|
||||
}
|
||||
# Create the branch and worktree manually to simulate partial state
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", journal["branch_name"]], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", journal["worktree_path"], journal["branch_name"]], check=True, capture_output=True)
|
||||
|
||||
# Run compensating recovery
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir)
|
||||
self.assertTrue(rec["executed"])
|
||||
self.assertIn(f"worktree_path:{journal['worktree_path']}", rec["rolled_back"])
|
||||
self.assertIn(f"branch:{journal['branch_name']}", rec["rolled_back"])
|
||||
|
||||
# Prove worktree directory and branch were rolled back
|
||||
self.assertFalse(os.path.exists(journal["worktree_path"]))
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", journal["branch_name"]], capture_output=True, text=True, check=False)
|
||||
self.assertNotEqual(branch_check.returncode, 0)
|
||||
|
||||
def test_cross_process_concurrency(self):
|
||||
"""Review #525 Finding 1: Genuine cross-process concurrency locking prevents corruption."""
|
||||
import concurrent.futures
|
||||
|
||||
key = "test_concurrent_key_850"
|
||||
args = (self.repo_dir, 850, key, self.lock_dir, self.journal_dir, self.master_sha)
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
|
||||
fut1 = executor.submit(_concurrent_bootstrap_worker, args)
|
||||
fut2 = executor.submit(_concurrent_bootstrap_worker, args)
|
||||
res1 = fut1.result(timeout=10)
|
||||
res2 = fut2.result(timeout=10)
|
||||
|
||||
self.assertTrue(res1["success"], f"res1 failed: {res1}")
|
||||
self.assertTrue(res2["success"], f"res2 failed: {res2}")
|
||||
# One process performs creation, the other process receives idempotent replay
|
||||
replayed_count = sum(1 for r in (res1, res2) if r.get("replayed"))
|
||||
created_count = sum(1 for r in (res1, res2) if not r.get("replayed"))
|
||||
self.assertEqual(replayed_count, 1)
|
||||
self.assertEqual(created_count, 1)
|
||||
self.assertEqual(res1["worktree_path"], res2["worktree_path"])
|
||||
|
||||
def test_interrupted_replay_preserves_artifacts_created_provenance(self):
|
||||
"""Review #525 Finding 2: Replaying incomplete journal preserves creation provenance monotonically."""
|
||||
key = "test_key_interrupted_replay_1"
|
||||
branch = "fix/issue-850-interrupted-replay"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-interrupted-replay")
|
||||
|
||||
# Simulate Phase 2/3 completion where branch and worktree directory were created by this transition
|
||||
journal = {
|
||||
"idempotency_key": key,
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"worktree_path": wt_path,
|
||||
"active_identity": "jcwalker3",
|
||||
"active_profile": "prgs-author",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"phases": {
|
||||
author_issue_bootstrap.PHASE_1_REQUEST_ACCEPTED: {"status": "completed"},
|
||||
author_issue_bootstrap.PHASE_2_BRANCH_CONFIRMED: {"status": "completed", "created": True},
|
||||
},
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
"lock_created": False,
|
||||
},
|
||||
"current_phase": author_issue_bootstrap.PHASE_3_PATH_RESERVED,
|
||||
"completed": False,
|
||||
}
|
||||
# Pre-create the branch and worktree on disk to simulate partial state after crash
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
author_issue_bootstrap.save_phase_journal(journal, journal_dir=self.lock_dir)
|
||||
|
||||
# Now resume/replay the transition but simulate lock binding failure during Phase 6
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Lock failure test")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=branch,
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "issue_lock_acquisition_failed")
|
||||
|
||||
# Verify that compensating recovery correctly deleted transition-created branch & worktree
|
||||
# because creation provenance was preserved across replay (NOT downgraded to False!)
|
||||
self.assertFalse(os.path.exists(wt_path))
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", branch], capture_output=True, text=True, check=False)
|
||||
self.assertNotEqual(branch_check.returncode, 0)
|
||||
|
||||
def test_transition_created_only_compensation(self):
|
||||
"""Review #525 Finding 4: Preexisting branch is NOT deleted by compensation when only worktree was transition-created."""
|
||||
key = "test_key_preexisting_branch_compensation"
|
||||
preexisting_branch = "fix/issue-850-preexisting"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-preexisting")
|
||||
|
||||
# Create branch BEFORE bootstrap (preexisting branch)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", preexisting_branch, self.master_sha], check=True, capture_output=True)
|
||||
|
||||
# Call bootstrap with simulated failure during Phase 6 (lock binding)
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Simulated lock failure")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=preexisting_branch,
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
# Worktree dir was created by transition -> removed by compensation
|
||||
self.assertFalse(os.path.exists(wt_path))
|
||||
|
||||
# Preexisting branch was NOT created by transition -> MUST BE PRESERVED!
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", preexisting_branch], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(branch_check.returncode, 0, "Preexisting branch was deleted by mistake!")
|
||||
|
||||
def test_incompatible_idempotency_replay_refusal(self):
|
||||
"""Review #525 Finding 4: Replaying key with incompatible parameters returns refusal."""
|
||||
key = "test_key_incompatible_replay"
|
||||
res1 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name="fix/issue-850-param-a",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"])
|
||||
|
||||
# Second call with different branch_name
|
||||
res2 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name="fix/issue-850-param-b",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res2["success"])
|
||||
self.assertEqual(res2.get("reason_code"), "incompatible_idempotency_replay")
|
||||
|
||||
def test_exact_next_action_satisfiable_via_mcp(self):
|
||||
"""Review #525 Finding 4: exact_next_action provides satisfiable MCP actions, not shell commands."""
|
||||
key = "test_key_next_action_mcp"
|
||||
stale_sha = "0000000000000000000000000000000000000000"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
next_action = res.get("exact_next_action", "")
|
||||
self.assertNotIn("scripts/worktree-start", next_action)
|
||||
self.assertNotIn("git worktree add", next_action)
|
||||
self.assertNotIn("bash", next_action.lower())
|
||||
|
||||
def test_missing_owner_session_refusal(self):
|
||||
"""Finding D: Missing owner_session context fails closed with typed refusal and zero mutation."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_owner_session")
|
||||
self.assertIn("exact_next_action", res)
|
||||
|
||||
def test_symlink_lock_file_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses to follow symlinks."""
|
||||
key = "test_symlink_lock_key"
|
||||
safe_key = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in key)
|
||||
lock_path = os.path.join(self.lock_dir, f"{safe_key}.lock")
|
||||
target_file = os.path.join(self.tmp_dir, "fake_target")
|
||||
with open(target_file, "w") as f:
|
||||
f.write("target")
|
||||
os.symlink(target_file, lock_path)
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with author_issue_bootstrap.BootstrapTransitionLock(key, journal_dir=self.lock_dir):
|
||||
pass
|
||||
self.assertIn("symlink", str(ctx.exception).lower())
|
||||
|
||||
def test_lock_directory_escape_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses keys that escape lock directory."""
|
||||
with mock.patch("os.path.abspath", return_value="/tmp/outside/evil_key.lock"):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
author_issue_bootstrap.BootstrapTransitionLock("key", journal_dir=self.lock_dir)
|
||||
self.assertIn("escapes", str(ctx.exception).lower())
|
||||
|
||||
def test_missing_active_identity_refusal(self):
|
||||
"""F-5: Missing active_identity parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity=None,
|
||||
active_profile="prgs-author",
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_identity")
|
||||
|
||||
def test_missing_active_profile_refusal(self):
|
||||
"""F-5: Missing active_profile parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity="jcwalker3",
|
||||
active_profile=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_profile")
|
||||
|
||||
def test_dirty_worktree_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete dirty worktree."""
|
||||
branch = "fix/issue-850-rec-dirty"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-dirty")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", "-b", branch, wt_path], check=True, capture_output=True)
|
||||
dirty_file = os.path.join(wt_path, "dirty.txt")
|
||||
with open(dirty_file, "w") as f:
|
||||
f.write("uncommitted work")
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_dirty_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"worktree_path": wt_path,
|
||||
"artifacts_created": {
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
},
|
||||
"failure_reason": "test dirty recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
self.assertTrue(os.path.exists(wt_path))
|
||||
self.assertIn(f"worktree_path_preserved_dirty:{wt_path}", rec["rolled_back"])
|
||||
|
||||
def test_branch_with_commits_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete branch with author commits."""
|
||||
branch = "fix/issue-850-rec-commits"
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
# Add a commit on the branch
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-commits")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
cfile = os.path.join(wt_path, "commit.txt")
|
||||
with open(cfile, "w") as f:
|
||||
f.write("author commit")
|
||||
subprocess.run(["git", "-C", wt_path, "add", "commit.txt"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", wt_path, "commit", "-m", "author commit"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "remove", "--force", wt_path], check=True, capture_output=True)
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_commits_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"resolved_base_sha": self.master_sha,
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
},
|
||||
"failure_reason": "test commit branch recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", branch], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(branch_check.returncode, 0, "Branch with commits was deleted!")
|
||||
self.assertIn(f"branch_preserved_commits:{branch}", rec["rolled_back"])
|
||||
|
||||
def test_task_capability_map_integration(self):
|
||||
"""Verify task_capability_map has bootstrap_author_issue_worktree configured correctly."""
|
||||
self.assertEqual(task_capability_map.required_role("bootstrap_author_issue_worktree"), "author")
|
||||
self.assertEqual(task_capability_map.required_permission("bootstrap_author_issue_worktree"), "gitea.branch.create")
|
||||
self.assertTrue(task_capability_map.preflight_task_matches("work_issue", "bootstrap_author_issue_worktree"))
|
||||
self.assertTrue(task_capability_map.preflight_task_matches("bootstrap_author_issue_worktree", "lock_issue"))
|
||||
|
||||
def test_unverified_assignment_lease_ids_fail_closed(self):
|
||||
"""Review #531 Finding 4: fabricated assignment/lease IDs are refused."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
assignment_id="asn-fabricated",
|
||||
lease_id="lease-fabricated",
|
||||
expected_base_sha=self.master_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIn(
|
||||
res.get("reason_code"),
|
||||
{
|
||||
"unknown_lease_id",
|
||||
"assignment_lease_lookup_failed",
|
||||
"incomplete_assignment_lease_ids",
|
||||
},
|
||||
)
|
||||
|
||||
def test_partial_assignment_lease_ids_fail_closed(self):
|
||||
"""Review #531 Finding 4: one of assignment_id/lease_id alone is incomplete."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
assignment_id="asn-only",
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "incomplete_assignment_lease_ids")
|
||||
|
||||
def test_stale_diverged_branch_is_not_accepted_via_merge_base(self):
|
||||
"""Review #531 Finding 3: any common ancestor is not enough; require master ⊆ branch."""
|
||||
branch = "fix/issue-850-stale-divergent"
|
||||
# Create branch from current master, then advance master so branch lacks tip.
|
||||
subprocess.run(
|
||||
["git", "-C", self.repo_dir, "branch", branch, self.master_sha],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Make a new commit on master (orphan path so branch does not contain it).
|
||||
marker = os.path.join(self.repo_dir, "master-advance.txt")
|
||||
with open(marker, "w") as f:
|
||||
f.write("advance master\n")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "add", "master-advance.txt"], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", self.repo_dir, "commit", "-m", "advance master past branch"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
new_master = subprocess.run(
|
||||
["git", "-C", self.repo_dir, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=branch,
|
||||
expected_base_sha=new_master,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "incompatible_existing_branch")
|
||||
|
||||
def test_compensating_recovery_attempts_lease_release(self):
|
||||
"""Review #531 Finding 5: recovery invokes lease release when lease_id is present."""
|
||||
from unittest import mock
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_lease_rec",
|
||||
"issue_number": 850,
|
||||
"owner_session": "session-test-1234",
|
||||
"lease_id": "lease-abc",
|
||||
"branch_name": "fix/issue-850-lease-rec",
|
||||
"artifacts_created": {"lock_created": True},
|
||||
"failure_reason": "simulated",
|
||||
"completed": False,
|
||||
}
|
||||
with mock.patch.object(
|
||||
author_issue_bootstrap.lease_lifecycle,
|
||||
"release_lease",
|
||||
return_value={"success": True},
|
||||
) as rel, mock.patch.object(
|
||||
author_issue_bootstrap.control_plane_db,
|
||||
"ControlPlaneDB",
|
||||
return_value=mock.Mock(),
|
||||
):
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(
|
||||
journal, self.repo_dir, journal_dir=self.lock_dir
|
||||
)
|
||||
self.assertTrue(rec["executed"])
|
||||
rel.assert_called_once()
|
||||
self.assertIn("lease:lease-abc", rec["rolled_back"])
|
||||
|
||||
|
||||
class TestCanonicalRootNoStringSplit(unittest.TestCase):
|
||||
def test_fallback_uses_commonpath_not_substring_split(self):
|
||||
"""Review #531 Finding 2: no norm.split('/branches/') fallback."""
|
||||
import inspect
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
src = inspect.getsource(amw.resolve_canonical_repo_root)
|
||||
self.assertNotIn('split("/branches/")', src)
|
||||
self.assertNotIn("split('/branches/')", src)
|
||||
|
||||
# Fallback recovers repo root from a nested branches worktree path.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = os.path.join(tmp, "repo")
|
||||
wt = os.path.join(repo, "branches", "fix-issue-850-x")
|
||||
os.makedirs(wt)
|
||||
# git unavailable path: pass missing workspace so fallback is used.
|
||||
root = amw.resolve_canonical_repo_root("/missing/path", wt)
|
||||
self.assertEqual(root, os.path.realpath(repo))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,6 +28,21 @@ class TestPathUnderBranches(unittest.TestCase):
|
||||
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
|
||||
)
|
||||
|
||||
def test_unrelated_branches_dir_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches("/tmp/branches/evil", self.ROOT)
|
||||
)
|
||||
|
||||
def test_prefix_confusion_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches-other/foo", self.ROOT)
|
||||
)
|
||||
|
||||
def test_traversal_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches/../evil", self.ROOT)
|
||||
)
|
||||
|
||||
|
||||
class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
@@ -71,6 +86,13 @@ class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_path_shaped_branches_ancestry_without_isdir(self):
|
||||
"""Review #551: commonpath recovery must not require on-disk isdir."""
|
||||
fake_wt = "/repo/Gitea-Tools/branches/issue-274"
|
||||
root = amw.resolve_canonical_repo_root(fake_wt, fake_wt)
|
||||
self.assertEqual(root, "/repo/Gitea-Tools")
|
||||
self.assertNotIn('split("/branches/")', open(amw.__file__).read())
|
||||
|
||||
|
||||
class TestPreflightIntegration(unittest.TestCase):
|
||||
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
|
||||
|
||||
@@ -1266,6 +1266,730 @@ 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)
|
||||
|
||||
|
||||
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__":
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import timedelta
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
ControlPlaneError,
|
||||
InvalidWorkKindError,
|
||||
LeaseRequiredError,
|
||||
WORK_KINDS,
|
||||
@@ -36,7 +37,7 @@ class ControlPlaneDBTest(unittest.TestCase):
|
||||
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(rows["schema_version"], "4")
|
||||
self.assertEqual(rows["schema_version"], "5")
|
||||
self.assertIn("DB coordinates", rows["architecture"])
|
||||
self.assertIn("bridge", rows["architecture"].lower())
|
||||
|
||||
@@ -820,5 +821,229 @@ class ControlPlaneDBTest(unittest.TestCase):
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
|
||||
class SessionCheckpointTest(unittest.TestCase):
|
||||
"""Durable MCP session checkpoint schema, redaction, and reconcile (#660)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _write(self, **overrides):
|
||||
base = dict(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
session_id="prgs-author-1-abc",
|
||||
role="author",
|
||||
work_kind="issue",
|
||||
work_number=660,
|
||||
branch="feat/issue-660-session-checkpoint-schema",
|
||||
head_sha="deadbeef",
|
||||
lease_id="lease-1",
|
||||
workflow_stage="implementing",
|
||||
last_completed_action="wrote schema",
|
||||
next_valid_action="write tests",
|
||||
recovery_instructions="re-lock #660 then continue tests",
|
||||
)
|
||||
base.update(overrides)
|
||||
return self.db.write_session_checkpoint(**base)
|
||||
|
||||
# AC1 — schema documented and versioned.
|
||||
def test_table_exists_and_row_carries_schema_version(self) -> None:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
names = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertIn("session_checkpoints", names)
|
||||
record = self._write()
|
||||
self.assertEqual(record["checkpoint_schema_version"], 5)
|
||||
|
||||
# AC2 — checkpoints written for multi-role session fixtures.
|
||||
def test_multi_role_fixtures_each_get_a_row(self) -> None:
|
||||
roles = [
|
||||
("prgs-author-1", "author", "issue", 660),
|
||||
("prgs-reviewer-2", "reviewer", "pr", 795),
|
||||
("prgs-merger-3", "merger", "pr", 862),
|
||||
("prgs-controller-4", "controller", "issue", 653),
|
||||
]
|
||||
for session_id, role, kind, number in roles:
|
||||
self._write(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
work_kind=kind,
|
||||
work_number=number,
|
||||
lease_id=f"lease-{session_id}",
|
||||
)
|
||||
rows = self.db.list_session_checkpoints(remote="prgs")
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual(
|
||||
{r["role"] for r in rows},
|
||||
{"author", "reviewer", "merger", "controller"},
|
||||
)
|
||||
|
||||
def test_upsert_is_current_state_and_audits_stage_change(self) -> None:
|
||||
first = self._write(workflow_stage="implementing")
|
||||
second = self._write(workflow_stage="testing")
|
||||
self.assertEqual(first["checkpoint_id"], second["checkpoint_id"])
|
||||
rows = self.db.list_session_checkpoints(
|
||||
remote="prgs", session_id="prgs-author-1-abc"
|
||||
)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["workflow_stage"], "testing")
|
||||
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM events "
|
||||
"WHERE event_type = 'session_checkpoint_stage_change'"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
def test_get_and_roundtrip_json_fields(self) -> None:
|
||||
self._write(
|
||||
capabilities=["gitea.repo.commit", "gitea.pr.create"],
|
||||
evidence={"tests": "4 passing"},
|
||||
pending_mutation={"op": "commit_files", "files": ["control_plane_db.py"]},
|
||||
)
|
||||
got = self.db.get_session_checkpoint(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
session_id="prgs-author-1-abc",
|
||||
work_kind="issue",
|
||||
work_number=660,
|
||||
)
|
||||
self.assertIsNotNone(got)
|
||||
self.assertEqual(got["capabilities"], ["gitea.repo.commit", "gitea.pr.create"])
|
||||
self.assertEqual(got["evidence"], {"tests": "4 passing"})
|
||||
self.assertEqual(got["pending_mutation"]["op"], "commit_files")
|
||||
|
||||
# AC4 — no secrets in stored records.
|
||||
def test_secrets_are_redacted_before_storage(self) -> None:
|
||||
self._write(
|
||||
recovery_instructions=(
|
||||
"resume with Authorization: Bearer sk-supersecrettoken then retry"
|
||||
),
|
||||
evidence={"authorization": "Bearer sk-anothersecret"},
|
||||
pending_mutation={"url": "https://user:[email protected]/repo.git"},
|
||||
)
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT recovery_instructions, evidence, pending_mutation "
|
||||
"FROM session_checkpoints"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
blob = " ".join(str(v) for v in row)
|
||||
self.assertNotIn("sk-supersecrettoken", blob)
|
||||
self.assertNotIn("sk-anothersecret", blob)
|
||||
self.assertNotIn("password", blob)
|
||||
self.assertIn("REDACTED", blob)
|
||||
|
||||
# AC3 — reconcile detects stale head / lease mismatch.
|
||||
def test_reconcile_flags_stale_head(self) -> None:
|
||||
record = self._write(head_sha="aaaa1111")
|
||||
result = self.db.reconcile_session_checkpoint(
|
||||
record, live_head_sha="bbbb2222", live_lease_active=True,
|
||||
live_lease_id="lease-1",
|
||||
)
|
||||
self.assertTrue(result["stale"])
|
||||
self.assertTrue(result["head_mismatch"])
|
||||
self.assertFalse(result["lease_mismatch"])
|
||||
self.assertEqual(result["reconcile_action"], "reconcile_required")
|
||||
|
||||
def test_reconcile_flags_dead_lease(self) -> None:
|
||||
record = self._write(lease_id="lease-1", head_sha="aaaa1111")
|
||||
result = self.db.reconcile_session_checkpoint(
|
||||
record, live_head_sha="aaaa1111", live_lease_active=False,
|
||||
)
|
||||
self.assertTrue(result["stale"])
|
||||
self.assertFalse(result["head_mismatch"])
|
||||
self.assertTrue(result["lease_mismatch"])
|
||||
|
||||
def test_reconcile_reassigned_lease_is_stale(self) -> None:
|
||||
record = self._write(lease_id="lease-1")
|
||||
result = self.db.reconcile_session_checkpoint(
|
||||
record, live_lease_active=True, live_lease_id="lease-999",
|
||||
)
|
||||
self.assertTrue(result["lease_mismatch"])
|
||||
|
||||
def test_reconcile_clean_state_is_safe_to_resume(self) -> None:
|
||||
record = self._write(head_sha="aaaa1111", lease_id="lease-1")
|
||||
result = self.db.reconcile_session_checkpoint(
|
||||
record, live_head_sha="aaaa1111", live_lease_active=True,
|
||||
live_lease_id="lease-1",
|
||||
)
|
||||
self.assertFalse(result["stale"])
|
||||
self.assertEqual(result["reconcile_action"], "safe_to_resume")
|
||||
|
||||
def test_unknown_live_state_never_flags_mismatch(self) -> None:
|
||||
record = self._write(head_sha="aaaa1111", lease_id="lease-1")
|
||||
result = self.db.reconcile_session_checkpoint(record)
|
||||
self.assertFalse(result["stale"])
|
||||
|
||||
# Drain gate — fail closed when a checkpoint is incomplete.
|
||||
def test_drain_requires_complete_checkpoint(self) -> None:
|
||||
with self.assertRaises(ControlPlaneError):
|
||||
self.db.write_session_checkpoint(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
session_id="prgs-author-1-abc",
|
||||
role="author",
|
||||
workflow_stage="draining",
|
||||
# next_valid_action + recovery_instructions intentionally absent
|
||||
require_complete=True,
|
||||
)
|
||||
# Nothing was written.
|
||||
rows = self.db.list_session_checkpoints(remote="prgs")
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_drain_write_succeeds_when_complete(self) -> None:
|
||||
record = self._write(require_complete=True)
|
||||
self.assertEqual(record["status"], "active")
|
||||
self.assertEqual(self.db.checkpoint_completeness(record), [])
|
||||
|
||||
def test_missing_session_id_fails_closed(self) -> None:
|
||||
with self.assertRaises(ControlPlaneError):
|
||||
self.db.write_session_checkpoint(
|
||||
remote="prgs", org="o", repo="r", session_id="",
|
||||
)
|
||||
|
||||
def test_session_level_checkpoint_uses_sentinel_key(self) -> None:
|
||||
# No work unit -> ('', 0) sentinel; a second session-level write upserts.
|
||||
self.db.write_session_checkpoint(
|
||||
remote="prgs", org="o", repo="r", session_id="s-sess",
|
||||
workflow_stage="idle",
|
||||
)
|
||||
self.db.write_session_checkpoint(
|
||||
remote="prgs", org="o", repo="r", session_id="s-sess",
|
||||
workflow_stage="booting",
|
||||
)
|
||||
rows = self.db.list_session_checkpoints(remote="prgs", session_id="s-sess")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["work_kind"], "")
|
||||
self.assertEqual(rows[0]["work_number"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
"""Authoritative controller cross-role generic queue allocation (#840)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from allocator_service import (
|
||||
ALLOCATION_MODE_CROSS_ROLE,
|
||||
ALLOCATION_MODE_ROLE_SCOPED,
|
||||
OUTCOME_NO_SAFE,
|
||||
OUTCOME_PREVIEW,
|
||||
OUTCOME_WAIT,
|
||||
ROLE_AUTHOR,
|
||||
ROLE_CONTROLLER,
|
||||
ROLE_MERGER,
|
||||
ROLE_RECONCILER,
|
||||
ROLE_REVIEWER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
build_selection_dict,
|
||||
classify_skip,
|
||||
required_namespace_for_role,
|
||||
required_profile_for_role,
|
||||
resolve_allocation_mode,
|
||||
selected_action_for_candidate,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
import role_session_router
|
||||
from role_session_router import (
|
||||
ROUTE_ALLOWED,
|
||||
ROUTE_AMBIGUOUS,
|
||||
ROUTE_WRONG_ROLE,
|
||||
route_task_session,
|
||||
)
|
||||
import namespace_workspace_binding as nwb
|
||||
import task_capability_map
|
||||
|
||||
|
||||
class CrossRoleAllocationModeTest(unittest.TestCase):
|
||||
def test_controller_defaults_to_cross_role(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_allocation_mode(ROLE_CONTROLLER),
|
||||
ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
|
||||
def test_worker_defaults_to_role_scoped(self) -> None:
|
||||
for role in (ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER):
|
||||
self.assertEqual(
|
||||
resolve_allocation_mode(role),
|
||||
ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
|
||||
def test_explicit_modes(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_allocation_mode(ROLE_CONTROLLER, "role_scoped"),
|
||||
ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_allocation_mode(ROLE_AUTHOR, "cross_role"),
|
||||
ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
|
||||
|
||||
class CrossRoleSelectionPayloadTest(unittest.TestCase):
|
||||
def test_selection_contains_required_fields(self) -> None:
|
||||
c = WorkCandidate(
|
||||
kind="issue",
|
||||
number=840,
|
||||
labels=("status:ready",),
|
||||
title="cross-role",
|
||||
priority=20,
|
||||
)
|
||||
sel = build_selection_dict(
|
||||
c,
|
||||
active_role=ROLE_CONTROLLER,
|
||||
required_role=ROLE_AUTHOR,
|
||||
profile_name="prgs-controller",
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
self.assertEqual(sel["number"], 840)
|
||||
self.assertEqual(sel["kind"], "issue")
|
||||
self.assertEqual(sel["required_role"], ROLE_AUTHOR)
|
||||
self.assertEqual(sel["selected_action"], "implement")
|
||||
self.assertEqual(sel["action"], "implement")
|
||||
self.assertEqual(sel["required_profile"], "prgs-author")
|
||||
self.assertEqual(sel["required_namespace"], "gitea-author")
|
||||
self.assertEqual(sel["pinned"]["number"], 840)
|
||||
self.assertIsNone(sel["pinned"]["head_sha"])
|
||||
|
||||
def test_profile_prefix_preserved(self) -> None:
|
||||
self.assertEqual(
|
||||
required_profile_for_role(ROLE_REVIEWER, profile_name="dadeschools-controller"),
|
||||
"dadeschools-reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_namespace_for_role(ROLE_MERGER),
|
||||
"gitea-merger",
|
||||
)
|
||||
|
||||
def test_selected_actions_per_role(self) -> None:
|
||||
issue = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||
pr_review = WorkCandidate(kind="pr", number=2, head_sha="a" * 40)
|
||||
pr_rc = WorkCandidate(
|
||||
kind="pr",
|
||||
number=3,
|
||||
head_sha="b" * 40,
|
||||
request_changes_current_head=True,
|
||||
)
|
||||
pr_merge = WorkCandidate(
|
||||
kind="pr",
|
||||
number=4,
|
||||
head_sha="c" * 40,
|
||||
approval_on_current_head=True,
|
||||
mergeable=True,
|
||||
)
|
||||
pr_recon = WorkCandidate(
|
||||
kind="pr",
|
||||
number=5,
|
||||
head_sha="d" * 40,
|
||||
approval_contaminated=True,
|
||||
)
|
||||
self.assertEqual(selected_action_for_candidate(issue, ROLE_AUTHOR), "implement")
|
||||
self.assertEqual(
|
||||
selected_action_for_candidate(pr_rc, ROLE_AUTHOR),
|
||||
"address_pr_change_requests",
|
||||
)
|
||||
self.assertEqual(
|
||||
selected_action_for_candidate(pr_review, ROLE_REVIEWER), "review"
|
||||
)
|
||||
self.assertEqual(selected_action_for_candidate(pr_merge, ROLE_MERGER), "merge")
|
||||
self.assertEqual(
|
||||
selected_action_for_candidate(pr_recon, ROLE_RECONCILER),
|
||||
"reconcile_contaminated_approval",
|
||||
)
|
||||
|
||||
|
||||
class CrossRoleAllocateServiceTest(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 _alloc(self, **kwargs):
|
||||
defaults = dict(
|
||||
db=self.db,
|
||||
session_id="ctrl-session",
|
||||
role=ROLE_CONTROLLER,
|
||||
remote="prgs",
|
||||
org="org",
|
||||
repo="repo",
|
||||
candidates=[],
|
||||
apply=False,
|
||||
profile_name="prgs-controller",
|
||||
username="controller-bot",
|
||||
controller_instance_id="ctrl-1",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(**defaults)
|
||||
|
||||
def test_eligible_author_work(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=100,
|
||||
labels=("status:ready",),
|
||||
title="author work",
|
||||
priority=20,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
|
||||
self.assertIsNotNone(res["selected"])
|
||||
self.assertEqual(res["selected"]["number"], 100)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
self.assertEqual(res["selected_action"], "implement")
|
||||
self.assertEqual(res["required_profile"], "prgs-author")
|
||||
self.assertEqual(res["required_namespace"], "gitea-author")
|
||||
self.assertIn("allocate", res["controller_allowed_actions"])
|
||||
self.assertIn("merge", res["controller_forbidden_actions"])
|
||||
self.assertFalse(res["allocation_evidence"]["lease_created"])
|
||||
|
||||
def test_eligible_reviewer_work(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=200,
|
||||
head_sha="e" * 40,
|
||||
title="needs review",
|
||||
priority=30,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["selected"]["number"], 200)
|
||||
self.assertEqual(res["required_role"], ROLE_REVIEWER)
|
||||
self.assertEqual(res["selected_action"], "review")
|
||||
self.assertEqual(res["required_profile"], "prgs-reviewer")
|
||||
self.assertEqual(res["selected"]["pinned"]["head_sha"], "e" * 40)
|
||||
|
||||
def test_eligible_merger_work(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=300,
|
||||
head_sha="f" * 40,
|
||||
approval_on_current_head=True,
|
||||
mergeable=True,
|
||||
priority=40,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["selected"]["number"], 300)
|
||||
self.assertEqual(res["required_role"], ROLE_MERGER)
|
||||
self.assertEqual(res["selected_action"], "merge")
|
||||
|
||||
def test_eligible_reconciler_work(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=400,
|
||||
head_sha="1" * 40,
|
||||
approval_contaminated=True,
|
||||
priority=50,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["selected"]["number"], 400)
|
||||
self.assertEqual(res["required_role"], ROLE_RECONCILER)
|
||||
self.assertIn("reconcile", res["selected_action"])
|
||||
|
||||
def test_no_eligible_work(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=10,
|
||||
labels=("status:blocked",),
|
||||
blocked=True,
|
||||
priority=99,
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=11,
|
||||
labels=("status:ready",),
|
||||
dependency_unmet=True,
|
||||
dependency_reason="blocked by #10",
|
||||
priority=98,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
|
||||
self.assertIsNone(res["selected"])
|
||||
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
|
||||
|
||||
def test_leased_work_skipped(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=50,
|
||||
labels=("status:ready",),
|
||||
priority=20,
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=51,
|
||||
labels=("status:ready",),
|
||||
priority=10,
|
||||
),
|
||||
]
|
||||
# Seed a foreign lease on issue 50 via assign_and_lease under another session.
|
||||
other = allocate_next_work(
|
||||
self.db,
|
||||
session_id="other-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
remote="prgs",
|
||||
org="org",
|
||||
repo="repo",
|
||||
candidates=cands[:1],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id="other-ctrl",
|
||||
)
|
||||
self.assertEqual(other["outcome"], "assigned_work")
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertIsNotNone(res["selected"])
|
||||
self.assertEqual(res["selected"]["number"], 51)
|
||||
self.assertTrue(any(s["number"] == 50 for s in res["skipped"]))
|
||||
self.assertTrue(res["claims_excluded"])
|
||||
|
||||
def test_dependencies_skipped(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=1,
|
||||
labels=("status:ready",),
|
||||
priority=99,
|
||||
dependency_unmet=True,
|
||||
dependency_reason="needs #2",
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=2,
|
||||
labels=("status:ready",),
|
||||
priority=1,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["selected"]["number"], 2)
|
||||
skipped = {s["number"]: s["reason"] for s in res["skipped"]}
|
||||
self.assertIn(1, skipped)
|
||||
self.assertIn("needs #2", skipped[1])
|
||||
|
||||
def test_pagination_limit_only_truncates_skip_report(self) -> None:
|
||||
"""Ranking uses full inventory; reporting limit is MCP-layer only.
|
||||
|
||||
Service ranks all candidates; prove higher-priority eligible item
|
||||
wins even when many skipped precede it.
|
||||
"""
|
||||
cands = []
|
||||
for n in range(1, 30):
|
||||
cands.append(
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=n,
|
||||
labels=("status:ready",),
|
||||
priority=100 - n,
|
||||
dependency_unmet=True,
|
||||
dependency_reason=f"dep {n}",
|
||||
)
|
||||
)
|
||||
cands.append(
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=999,
|
||||
labels=("status:ready",),
|
||||
priority=1,
|
||||
)
|
||||
)
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["selected"]["number"], 999)
|
||||
self.assertGreaterEqual(len(res["skipped"]), 29)
|
||||
|
||||
def test_role_scoped_controller_legacy_still_restricts(self) -> None:
|
||||
"""role_scoped controller only takes reconciler-needed items."""
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=1,
|
||||
labels=("status:ready",),
|
||||
priority=50,
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=2,
|
||||
head_sha="a" * 40,
|
||||
approval_contaminated=True,
|
||||
priority=1,
|
||||
),
|
||||
]
|
||||
res = self._alloc(
|
||||
candidates=cands,
|
||||
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_ROLE_SCOPED)
|
||||
self.assertEqual(res["selected"]["number"], 2)
|
||||
self.assertEqual(res["required_role"], ROLE_RECONCILER)
|
||||
|
||||
def test_cross_role_prefers_highest_priority_across_roles(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=10,
|
||||
labels=("status:ready",),
|
||||
priority=10,
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=20,
|
||||
head_sha="b" * 40,
|
||||
priority=50,
|
||||
),
|
||||
WorkCandidate(
|
||||
kind="pr",
|
||||
number=30,
|
||||
head_sha="c" * 40,
|
||||
approval_on_current_head=True,
|
||||
mergeable=True,
|
||||
priority=20,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
# PR #20 highest priority → reviewer
|
||||
self.assertEqual(res["selected"]["number"], 20)
|
||||
self.assertEqual(res["required_role"], ROLE_REVIEWER)
|
||||
|
||||
def test_apply_creates_lease_evidence_for_required_role(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=777,
|
||||
labels=("status:ready",),
|
||||
priority=20,
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands, apply=True)
|
||||
self.assertEqual(res["outcome"], "assigned_work")
|
||||
self.assertTrue(res["allocation_evidence"]["lease_created"])
|
||||
self.assertEqual(res["allocation_evidence"]["lease_role"], ROLE_AUTHOR)
|
||||
proof = res["lease_proof"]
|
||||
self.assertIsNotNone(proof["lease_id"])
|
||||
self.assertEqual(proof["lease_role"], ROLE_AUTHOR)
|
||||
self.assertIn("implement", proof["allowed_actions"])
|
||||
# Controller isolation: controller still forbids merge/push/create_pr
|
||||
self.assertIn("merge", res["controller_forbidden_actions"])
|
||||
self.assertIn("push", res["controller_forbidden_actions"])
|
||||
|
||||
def test_metadata_consistency_role_is_controller(self) -> None:
|
||||
cands = [
|
||||
WorkCandidate(
|
||||
kind="issue",
|
||||
number=1,
|
||||
labels=("status:ready",),
|
||||
),
|
||||
]
|
||||
res = self._alloc(candidates=cands)
|
||||
self.assertEqual(res["role"], ROLE_CONTROLLER)
|
||||
self.assertEqual(res["routing_role"], ROLE_CONTROLLER)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
|
||||
|
||||
class ProcessWorkQueueRouterTest(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
role_session_router.clear_route_state()
|
||||
|
||||
def test_process_work_queue_allowed_for_controller(self) -> None:
|
||||
res = route_task_session(
|
||||
"process_work_queue",
|
||||
active_profile="prgs-controller",
|
||||
active_role_kind="controller",
|
||||
allowed_in_current_session=True,
|
||||
)
|
||||
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
|
||||
self.assertEqual(res["required_role"], "controller")
|
||||
self.assertTrue(res["downstream_allowed"])
|
||||
|
||||
def test_process_work_queue_hyphen_alias(self) -> None:
|
||||
res = route_task_session(
|
||||
"process-work-queue",
|
||||
active_profile="prgs-controller",
|
||||
active_role_kind="controller",
|
||||
allowed_in_current_session=True,
|
||||
)
|
||||
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
|
||||
|
||||
def test_process_work_queue_wrong_role_for_author(self) -> None:
|
||||
res = route_task_session(
|
||||
"process_work_queue",
|
||||
active_profile="prgs-author",
|
||||
active_role_kind="author",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(res["route_result"], ROUTE_WRONG_ROLE)
|
||||
self.assertEqual(res["required_role"], "controller")
|
||||
self.assertFalse(res["downstream_allowed"])
|
||||
|
||||
def test_unknown_still_ambiguous(self) -> None:
|
||||
res = route_task_session(
|
||||
"not_a_real_task",
|
||||
active_profile="prgs-controller",
|
||||
active_role_kind="controller",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(res["route_result"], ROUTE_AMBIGUOUS)
|
||||
|
||||
def test_capability_map_process_work_queue_is_controller(self) -> None:
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("process_work_queue"),
|
||||
"controller",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission("process_work_queue"),
|
||||
"gitea.read",
|
||||
)
|
||||
|
||||
|
||||
class ControllerRoleMetadataTest(unittest.TestCase):
|
||||
def test_normalize_role_kind_controller(self) -> None:
|
||||
self.assertEqual(
|
||||
nwb.normalize_role_kind("controller"),
|
||||
"controller",
|
||||
)
|
||||
self.assertEqual(
|
||||
nwb.normalize_role_kind("author", profile_name="prgs-controller"),
|
||||
"controller",
|
||||
)
|
||||
self.assertEqual(
|
||||
nwb.normalize_role_kind("reconciler", profile_name="prgs-controller"),
|
||||
"controller",
|
||||
)
|
||||
|
||||
def test_profile_role_kind_prefers_declared_controller(self) -> None:
|
||||
# Import from worktree package path via sys.path already set by pytest.
|
||||
import gitea_mcp_server as mcp
|
||||
|
||||
profile = {
|
||||
"profile_name": "prgs-controller",
|
||||
"role": "controller",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.close",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
}
|
||||
# Declared role wins even if permissions look reconciler-like.
|
||||
self.assertEqual(mcp._profile_role_kind(profile), "controller")
|
||||
# Name-based fallback.
|
||||
profile_no_role = dict(profile)
|
||||
profile_no_role["role"] = None
|
||||
profile_no_role["role_kind"] = None
|
||||
self.assertEqual(mcp._profile_role_kind(profile_no_role), "controller")
|
||||
|
||||
def test_permission_inference_without_controller_name_stays_reconciler(self) -> None:
|
||||
import gitea_mcp_server as mcp
|
||||
|
||||
# Pure permission inference still may return reconciler when no controller
|
||||
# declaration exists — that is intentional for reconciler profiles.
|
||||
role = mcp._role_kind(
|
||||
["gitea.read", "gitea.pr.close", "gitea.issue.comment"],
|
||||
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push"],
|
||||
)
|
||||
self.assertEqual(role, "reconciler")
|
||||
|
||||
|
||||
class DashboardRemainsExplanatoryTest(unittest.TestCase):
|
||||
def test_dashboard_prompt_points_at_allocator_not_self_select(self) -> None:
|
||||
import workflow_dashboard as wd
|
||||
|
||||
self.assertIn("gitea_allocate_next_work", wd.PROMPT_CONTROLLER)
|
||||
self.assertIn("process_work_queue", wd.PROMPT_CONTROLLER)
|
||||
self.assertIn("never replaces allocator", wd.PROMPT_CONTROLLER.lower())
|
||||
self.assertNotIn("self-select", wd.PROMPT_CONTROLLER.lower())
|
||||
|
||||
|
||||
class ClassifySkipCrossRoleTest(unittest.TestCase):
|
||||
def test_controller_cross_role_accepts_author_issue(self) -> None:
|
||||
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||
self.assertIsNone(
|
||||
classify_skip(
|
||||
c,
|
||||
role=ROLE_CONTROLLER,
|
||||
terminal_pr=None,
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
)
|
||||
|
||||
def test_legacy_controller_skips_author_issue(self) -> None:
|
||||
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||
reason = classify_skip(
|
||||
c,
|
||||
role=ROLE_CONTROLLER,
|
||||
terminal_pr=None,
|
||||
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
self.assertIsNotNone(reason)
|
||||
self.assertIn("does not require controller", reason or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""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"},
|
||||
)
|
||||
# #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))
|
||||
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)
|
||||
key = "symlink-test"
|
||||
jdir = tmp
|
||||
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)
|
||||
|
||||
|
||||
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", "[email protected]"], 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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
"""Regression tests for #628 building blocks (child scope only).
|
||||
|
||||
Honest scope: unit coverage of pre-existing APIs used by umbrella #628.
|
||||
This module does **not** implement or verify all 21 umbrella acceptance
|
||||
criteria, automatic handoff store/retrieve, multi-worker product wiring,
|
||||
or end-to-end orchestration.
|
||||
|
||||
Covered building blocks:
|
||||
- CTH format / parse / assess (`format_cth_body`, `parse_cth_comment`,
|
||||
`assess_cth_comment`)
|
||||
- Exclusive-ownership skip classification (`classify_skip` with
|
||||
OWNERSHIP_FOREIGN vs OWNERSHIP_OWN)
|
||||
- Durable dependency edges (`upsert_dependency_edge` / list) and skip
|
||||
when dependency_unmet
|
||||
- Edge state transition UNMET -> MET
|
||||
|
||||
Parent umbrella remains #628; this slice is a scoped child issue only.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from canonical_thread_handoff import (
|
||||
format_cth_body,
|
||||
parse_cth_comment,
|
||||
assess_cth_comment,
|
||||
)
|
||||
import dependency_graph
|
||||
from control_plane_db import ControlPlaneDB
|
||||
from allocator_service import (
|
||||
WorkCandidate,
|
||||
classify_skip,
|
||||
ROLE_AUTHOR,
|
||||
ROLE_REVIEWER,
|
||||
ROLE_MERGER,
|
||||
ROLE_RECONCILER,
|
||||
OWNERSHIP_OWN,
|
||||
OWNERSHIP_FOREIGN,
|
||||
)
|
||||
|
||||
|
||||
class TestIssue628Orchestration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_canonical_handoff_serialization_and_retrieval(self):
|
||||
"""Building block: format/parse/assess a CTH body (not full AC1/AC2 product path)."""
|
||||
handoff = format_cth_body(
|
||||
cth_type="Author Handoff",
|
||||
status="completed",
|
||||
next_owner="reviewer",
|
||||
current_blocker="none",
|
||||
decision="Implementation complete, tests passing",
|
||||
proof="pytest tests/test_issue_628_orchestration.py passed",
|
||||
next_action="Review PR and run reviewer pre-flight",
|
||||
ready_to_paste_prompt="Review PR for child issue #878 (parent #628)",
|
||||
)
|
||||
self.assertIn("CTH: Author Handoff", handoff)
|
||||
|
||||
parsed = parse_cth_comment(handoff)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(parsed["cth_type"], "Author Handoff")
|
||||
|
||||
assessment = assess_cth_comment(handoff)
|
||||
self.assertFalse(assessment["block"])
|
||||
|
||||
def test_exclusive_task_unit_single_owner(self):
|
||||
"""Building block: classify_skip foreign vs own ownership."""
|
||||
candidate = WorkCandidate(
|
||||
kind="issue",
|
||||
number=878,
|
||||
title="Child #878 ownership classify candidate",
|
||||
state="open",
|
||||
labels=["status:in-progress"],
|
||||
blocked=False,
|
||||
dependency_unmet=False,
|
||||
)
|
||||
# Foreign ownership MUST be skipped
|
||||
skip_foreign = classify_skip(
|
||||
c=candidate,
|
||||
role=ROLE_AUTHOR,
|
||||
terminal_pr=None,
|
||||
claim_ownership=OWNERSHIP_FOREIGN,
|
||||
)
|
||||
self.assertIsNotNone(skip_foreign)
|
||||
self.assertIn("active lease", skip_foreign)
|
||||
|
||||
# Own/Self claim remains selectable for session resumption
|
||||
skip_self = classify_skip(
|
||||
c=candidate,
|
||||
role=ROLE_AUTHOR,
|
||||
terminal_pr=None,
|
||||
claim_ownership=OWNERSHIP_OWN,
|
||||
)
|
||||
self.assertIsNone(skip_self)
|
||||
|
||||
def test_durable_dependency_graph_blocking(self):
|
||||
"""Building block: unmet dependency edge + classify_skip on dependency_unmet."""
|
||||
# Upsert a blocking dependency edge between issue 878 and blocker 601
|
||||
self.db.upsert_dependency_edge(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
source_kind="issue",
|
||||
source_number=878,
|
||||
target_kind="issue",
|
||||
target_number=601,
|
||||
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
state=dependency_graph.STATE_UNMET,
|
||||
blocking_condition="Target issue #601 is not closed",
|
||||
completion_condition="Target issue #601 is closed",
|
||||
evidence={"source": "unit_test"},
|
||||
)
|
||||
|
||||
edges = self.db.list_dependency_edges(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
source_kind="issue",
|
||||
source_number=878,
|
||||
)
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertEqual(edges[0]["state"], "unmet")
|
||||
self.assertEqual(edges[0]["target_number"], 601)
|
||||
|
||||
# When dependency is unmet, candidate is blocked from selection
|
||||
candidate = WorkCandidate(
|
||||
kind="issue",
|
||||
number=878,
|
||||
title="Blocked candidate",
|
||||
state="open",
|
||||
labels=[],
|
||||
blocked=False,
|
||||
dependency_unmet=True,
|
||||
dependency_reason="issue#878 is blocked by unmet dependency issue#601",
|
||||
)
|
||||
skip_reason = classify_skip(
|
||||
c=candidate,
|
||||
role=ROLE_AUTHOR,
|
||||
terminal_pr=None,
|
||||
claim_ownership=OWNERSHIP_OWN,
|
||||
)
|
||||
self.assertIsNotNone(skip_reason)
|
||||
self.assertIn("issue#601", skip_reason)
|
||||
|
||||
def test_dependency_completion_reevaluation(self):
|
||||
"""Building block: dependency edge state can transition UNMET -> MET."""
|
||||
self.db.upsert_dependency_edge(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
source_kind="issue",
|
||||
source_number=878,
|
||||
target_kind="issue",
|
||||
target_number=601,
|
||||
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
state=dependency_graph.STATE_UNMET,
|
||||
blocking_condition="Target issue #601 is open",
|
||||
completion_condition="Target issue #601 is closed",
|
||||
evidence={"source": "unit_test"},
|
||||
)
|
||||
|
||||
# Mark edge as met upon target issue closure
|
||||
self.db.upsert_dependency_edge(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
source_kind="issue",
|
||||
source_number=878,
|
||||
target_kind="issue",
|
||||
target_number=601,
|
||||
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||
state=dependency_graph.STATE_MET,
|
||||
blocking_condition="Target issue #601 is open",
|
||||
completion_condition="Target issue #601 is closed",
|
||||
evidence={"source": "target_closed_event"},
|
||||
)
|
||||
|
||||
edges = self.db.list_dependency_edges(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
source_kind="issue",
|
||||
source_number=878,
|
||||
)
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertEqual(edges[0]["state"], "met")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -91,6 +91,132 @@ def test_compound_command_detects_the_kill_half():
|
||||
assert result["contamination"] is True
|
||||
|
||||
|
||||
# ── #787: background separator and subshell forms reach the classifier ───────
|
||||
|
||||
def test_background_separator_kill_is_contamination():
|
||||
result = guard.classify_recovery_command("sleep 1 & pkill -f mcp_server.py")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_subshell_wrapped_kill_is_contamination():
|
||||
result = guard.classify_recovery_command("(pkill -f mcp_server.py)")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_further_background_and_subshell_forms_are_contamination():
|
||||
for command in (
|
||||
"pkill -f mcp_server.py &",
|
||||
"( sudo pkill -f mcp_server.py )",
|
||||
"((pkill -f gitea_mcp_server))",
|
||||
"sleep 1 & killall mcp_server",
|
||||
"(ps aux | grep mcp_server) & pkill -f mcp_server.py",
|
||||
):
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["contamination"] is True, command
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
|
||||
|
||||
|
||||
def test_logical_operators_are_not_split_into_single_characters():
|
||||
# ``&&``/``||`` must still be consumed whole by the separator scan.
|
||||
assert guard._split_segments("a && b || c") == ["a", "b", "c"]
|
||||
assert guard._split_segments("a & b") == ["a", "b"]
|
||||
assert guard._split_segments("(a)") == ["a"]
|
||||
assert guard._split_segments("a; b\nc | d") == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
# ── #789 F1: separators only separate outside quoted or escaped text ─────────
|
||||
|
||||
# The three commands the PR #789 review measured as regressions at head
|
||||
# 6b58f04: each merely *mentions* the canonical kill string inside quotes.
|
||||
F1_QUOTED_COMMANDS = (
|
||||
'git commit -m "block sleep 1 & pkill -f mcp_server.py as recovery"',
|
||||
'echo "docs: sleep 1 & pkill -f mcp_server.py is now detected"',
|
||||
'grep -rn "sleep 1 & pkill -f mcp_server.py" docs/',
|
||||
)
|
||||
|
||||
|
||||
def test_quoted_ampersand_examples_from_review_f1_are_not_kills():
|
||||
for command in F1_QUOTED_COMMANDS:
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False, command
|
||||
assert result["contamination"] is False, command
|
||||
assert result["reason_class"] is None, command
|
||||
|
||||
|
||||
def test_ampersand_inside_double_quotes_is_not_a_separator():
|
||||
assert guard._split_segments('echo "a & b"') == ['echo "a & b"']
|
||||
result = guard.classify_recovery_command(
|
||||
'echo "restart it: sleep 1 & pkill -f mcp_server.py"'
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_ampersand_inside_single_quotes_is_not_a_separator():
|
||||
assert guard._split_segments("echo 'a & b'") == ["echo 'a & b'"]
|
||||
result = guard.classify_recovery_command(
|
||||
"git commit -m 'sleep 1 & pkill -f mcp_server.py stays quoted'"
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_backslash_escaped_ampersand_is_not_a_separator():
|
||||
command = r"echo a \& pkill -f mcp_server.py"
|
||||
assert guard._split_segments(command) == [command]
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_backslash_does_not_escape_inside_single_quotes():
|
||||
# POSIX: a backslash is literal inside single quotes, so the closing quote
|
||||
# still closes and the following ``&`` is a genuinely active separator.
|
||||
command = r"echo 'a\' & pkill -f mcp_server.py"
|
||||
assert guard._split_segments(command) == [r"echo 'a\'", "pkill -f mcp_server.py"]
|
||||
assert guard.classify_recovery_command(command)["contamination"] is True
|
||||
|
||||
|
||||
def test_quote_awareness_also_retires_the_pre_existing_semicolon_and_pipe_cases():
|
||||
# ``;`` and ``|`` misclassified quoted text before #787 as well. The fix is
|
||||
# the quote-unawareness, not the ``&`` instance the issue happens to name.
|
||||
for command in (
|
||||
'git commit -m "fix; pkill -f mcp_server.py"',
|
||||
'git commit -m "fix | pkill -f mcp_server.py"',
|
||||
):
|
||||
result = guard.classify_recovery_command(command)
|
||||
assert result["process_kill"] is False, command
|
||||
assert result["contamination"] is False, command
|
||||
|
||||
|
||||
# ── #789 F3: subshell stripping and redirection stay syntactically honest ────
|
||||
|
||||
def test_command_substitution_is_not_mangled_by_subshell_stripping():
|
||||
# Only a wrapper this call opened may be unwrapped; a ``)`` closing ``$(``
|
||||
# must survive intact.
|
||||
assert guard._strip_subshell("kill $(pgrep -f myapp)") == "kill $(pgrep -f myapp)"
|
||||
result = guard.classify_recovery_command("kill $(pgrep -f myapp)")
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is True
|
||||
|
||||
|
||||
def test_redirection_is_not_treated_as_a_background_separator():
|
||||
assert guard._split_segments("a 2>&1") == ["a 2>&1"]
|
||||
assert guard._split_segments("a &> log") == ["a &> log"]
|
||||
assert guard._split_segments("pkill -f mcp_server.py 2>&1") == [
|
||||
"pkill -f mcp_server.py 2>&1"
|
||||
]
|
||||
result = guard.classify_recovery_command("pkill -f mcp_server.py 2>&1")
|
||||
assert result["contamination"] is True
|
||||
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
|
||||
|
||||
# ── no false positives ───────────────────────────────────────────────────────
|
||||
|
||||
def test_read_only_inspection_is_not_a_kill():
|
||||
@@ -112,6 +238,22 @@ def test_unrelated_pkill_target_is_not_contamination():
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_user_scoped_pkill_of_unrelated_app_is_not_contamination():
|
||||
# ``-u`` consumes ``mcpuser``; the surviving operand names no daemon (#787).
|
||||
result = guard.classify_recovery_command("pkill -u mcpuser -f myapp")
|
||||
assert result["process_kill"] is True
|
||||
assert result["contamination"] is False
|
||||
assert result["ambiguous"] is False
|
||||
|
||||
|
||||
def test_commit_message_quoting_the_kill_string_is_not_a_kill():
|
||||
result = guard.classify_recovery_command(
|
||||
'git commit -m "block pkill -f mcp_server.py as workflow recovery"'
|
||||
)
|
||||
assert result["process_kill"] is False
|
||||
assert result["contamination"] is False
|
||||
|
||||
|
||||
def test_bare_kill_of_unknown_pid_is_ambiguous_not_contamination():
|
||||
result = guard.classify_recovery_command("kill 31337")
|
||||
assert result["contamination"] is False
|
||||
@@ -333,6 +475,43 @@ def test_record_tool_marks_manual_daemon_kill():
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_marks_background_separator_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="sleep 1 & pkill -f mcp_server.py", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||
assert loaded is not None
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_marks_subshell_wrapped_kill():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
command="(pkill -f mcp_server.py)", remote="prgs"
|
||||
)
|
||||
assert res["contaminated"] is True
|
||||
assert res["marked"] is True
|
||||
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||
assert loaded is not None
|
||||
assert "mcp_server.py" in loaded["command_summary"]
|
||||
|
||||
|
||||
def test_record_tool_does_not_mark_a_quoted_mention_of_the_kill_string():
|
||||
# The marker is what fails review/merge/close closed and only a reconciler
|
||||
# may clear it, so a quoted mention must never create one (PR #789 F1).
|
||||
for command in F1_QUOTED_COMMANDS:
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(command=command, remote="prgs")
|
||||
assert res["contaminated"] is False, command
|
||||
assert res["marked"] is False, command
|
||||
assert srv._load_runtime_recovery_marker("prgs") is None, command
|
||||
|
||||
|
||||
def test_record_tool_marks_broad_sweep():
|
||||
_clear_marker()
|
||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for post-restart MCP reconciliation and completion proof (#662)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import post_restart_reconcile as prr
|
||||
|
||||
NOW = datetime(2026, 7, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _base_inventory(**overrides):
|
||||
inv = {
|
||||
"inventory_complete": True,
|
||||
"incomplete_reasons": [],
|
||||
"service_health": {"healthy": True},
|
||||
"clients": [{"session_id": "c1", "connected": True}],
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "s-live",
|
||||
"status": "active",
|
||||
"pid": os.getpid(),
|
||||
"role": "author",
|
||||
}
|
||||
],
|
||||
"leases": [],
|
||||
"checkpoints_available": False,
|
||||
"worktree_bindings": [{"path": "/tmp/wt", "exists": True}],
|
||||
"pending_mutations": [],
|
||||
"capabilities": {"stale": False},
|
||||
"boot_head_sha": "a" * 40,
|
||||
"current_head_sha": "a" * 40,
|
||||
"queue_state": {"safe_to_resume": True},
|
||||
}
|
||||
inv.update(overrides)
|
||||
return inv
|
||||
|
||||
|
||||
class IncompleteInventoryTest(unittest.TestCase):
|
||||
def test_incomplete_inventory_fails_closed(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
{
|
||||
"inventory_complete": False,
|
||||
"incomplete_reasons": ["control-plane DB unavailable"],
|
||||
},
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
reconcile_id="test-incomplete",
|
||||
)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_FAILED)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
self.assertFalse(proof.inventory_complete)
|
||||
self.assertTrue(proof.proposed_follow_ups)
|
||||
self.assertIn("control-plane DB unavailable", proof.incomplete_reasons)
|
||||
|
||||
|
||||
class HappyPathTest(unittest.TestCase):
|
||||
def test_clean_restart_is_complete_without_mutation_hold(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
reconcile_id="test-clean",
|
||||
)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_COMPLETE)
|
||||
self.assertFalse(proof.mutation_hold)
|
||||
self.assertEqual(proof.unresolved_count, 0)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_SKIPPED)
|
||||
links = proof.as_dict()["links"]
|
||||
self.assertEqual(links["umbrella"], 655)
|
||||
self.assertEqual(links["issue"], 662)
|
||||
self.assertEqual(links["vision"], 652)
|
||||
self.assertEqual(links["roadmap"], 653)
|
||||
|
||||
|
||||
class InterruptedMutationTest(unittest.TestCase):
|
||||
def test_mutating_lease_with_dead_owner_is_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
leases=[
|
||||
{
|
||||
"lease_id": "lease-mut",
|
||||
"session_id": "s-dead",
|
||||
"phase": "implementing",
|
||||
"work_kind": "issue",
|
||||
"work_number": 662,
|
||||
"worktree_path": "/tmp/wt-662",
|
||||
"freshness": {"freshness": "stale_dead_process"},
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
mut = next(i for i in proof.items if i.dimension == prr.DIM_MUTATIONS)
|
||||
self.assertEqual(mut.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertTrue(mut.follow_up_required)
|
||||
interrupted = mut.details["interrupted"]
|
||||
self.assertEqual(len(interrupted), 1)
|
||||
self.assertFalse(interrupted[0]["resume_allowed"])
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
self.assertTrue(
|
||||
any(f.dimension == prr.DIM_MUTATIONS for f in proof.proposed_follow_ups)
|
||||
)
|
||||
|
||||
def test_explicit_pending_mutation_inventory(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
pending_mutations=[
|
||||
{
|
||||
"session_id": "s1",
|
||||
"phase": "publishing",
|
||||
"work_kind": "pr",
|
||||
"work_number": 856,
|
||||
"reason": "push interrupted mid-flight",
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_LOG_ONLY,
|
||||
)
|
||||
mut = next(i for i in proof.items if i.dimension == prr.DIM_MUTATIONS)
|
||||
self.assertEqual(mut.status, prr.ITEM_UNRESOLVED)
|
||||
# log_only never holds mutations even when unresolved
|
||||
self.assertFalse(proof.mutation_hold)
|
||||
self.assertEqual(proof.overall_status, prr.STATUS_DEGRADED)
|
||||
|
||||
|
||||
class DuplicateClaimsTest(unittest.TestCase):
|
||||
def test_duplicate_live_claims_flagged(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
leases=[
|
||||
{
|
||||
"lease_id": "l1",
|
||||
"session_id": "s1",
|
||||
"phase": "allocated",
|
||||
"work_kind": "issue",
|
||||
"work_number": 100,
|
||||
"freshness": {"freshness": "active"},
|
||||
},
|
||||
{
|
||||
"lease_id": "l2",
|
||||
"session_id": "s2",
|
||||
"phase": "allocated",
|
||||
"work_kind": "issue",
|
||||
"work_number": 100,
|
||||
"freshness": {"freshness": "active"},
|
||||
},
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
dups = next(i for i in proof.items if i.dimension == prr.DIM_DUPLICATES)
|
||||
self.assertEqual(dups.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertEqual(dups.details["duplicates"][0]["claim_count"], 2)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
|
||||
|
||||
class OrphanSessionTest(unittest.TestCase):
|
||||
def test_active_session_dead_pid_is_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
sessions=[
|
||||
{
|
||||
"session_id": "ghost",
|
||||
"status": "active",
|
||||
"pid": 2_000_000_000,
|
||||
"role": "author",
|
||||
}
|
||||
]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
sess = next(i for i in proof.items if i.dimension == prr.DIM_SESSIONS)
|
||||
self.assertEqual(sess.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertIn("ghost", sess.details["orphan_session_ids"])
|
||||
|
||||
|
||||
class CapabilityStaleTest(unittest.TestCase):
|
||||
def test_stale_runtime_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(capabilities={"stale": True, "startup_head": "aaa"}),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
caps = next(i for i in proof.items if i.dimension == prr.DIM_CAPABILITIES)
|
||||
self.assertEqual(caps.status, prr.ITEM_UNRESOLVED)
|
||||
self.assertTrue(proof.mutation_hold)
|
||||
|
||||
|
||||
class MutationsAllowedHelperTest(unittest.TestCase):
|
||||
def test_mutations_allowed_respects_hold(self) -> None:
|
||||
held = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
pending_mutations=[{"phase": "merging", "session_id": "x"}]
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
self.assertFalse(prr.mutations_allowed(held))
|
||||
self.assertFalse(prr.mutations_allowed(held.as_dict()))
|
||||
clean = prr.reconcile_after_restart(
|
||||
_base_inventory(), now=NOW, mode=prr.MODE_ENFORCE
|
||||
)
|
||||
self.assertTrue(prr.mutations_allowed(clean))
|
||||
|
||||
|
||||
class CheckpointSoftDependencyTest(unittest.TestCase):
|
||||
def test_checkpoints_when_schema_present(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
checkpoints_available=True,
|
||||
checkpoints=[{"session_id": "s1", "stale": False}],
|
||||
),
|
||||
now=NOW,
|
||||
)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_RESOLVED)
|
||||
|
||||
def test_stale_checkpoints_unresolved(self) -> None:
|
||||
proof = prr.reconcile_after_restart(
|
||||
_base_inventory(
|
||||
checkpoints_available=True,
|
||||
checkpoints=[{"session_id": "s1", "stale": True}],
|
||||
),
|
||||
now=NOW,
|
||||
mode=prr.MODE_ENFORCE,
|
||||
)
|
||||
cp = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||
self.assertEqual(cp.status, prr.ITEM_UNRESOLVED)
|
||||
|
||||
|
||||
class ProofSerializationTest(unittest.TestCase):
|
||||
def test_as_dict_is_json_friendly(self) -> None:
|
||||
proof = prr.reconcile_after_restart(_base_inventory(), now=NOW)
|
||||
blob = json.dumps(proof.as_dict())
|
||||
self.assertIn("reconcile_id", blob)
|
||||
self.assertIn("proposed_follow_ups", blob)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Starlette TestClient uses httpx2 without deprecation warning (#682)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
class TestHttpx2Installed(unittest.TestCase):
|
||||
def test_httpx2_importable(self) -> None:
|
||||
httpx2 = importlib.import_module("httpx2")
|
||||
self.assertTrue(hasattr(httpx2, "Client") or hasattr(httpx2, "AsyncClient"))
|
||||
|
||||
|
||||
class TestNoStarletteTestClientDeprecation(unittest.TestCase):
|
||||
def test_starlette_testclient_import_does_not_warn(self) -> None:
|
||||
# Force a fresh import path so a previous httpx-fallback warning cannot
|
||||
# hide a regression after httpx2 is present. Restore sys.modules afterwards.
|
||||
saved = {
|
||||
name: sys.modules[name]
|
||||
for name in list(sys.modules)
|
||||
if name == "starlette.testclient" or name.startswith("starlette.testclient.")
|
||||
}
|
||||
try:
|
||||
for name in list(saved):
|
||||
del sys.modules[name]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
import starlette.testclient as testclient # noqa: F401
|
||||
|
||||
dep = [
|
||||
w
|
||||
for w in caught
|
||||
if "httpx" in str(w.message).lower()
|
||||
and "deprecated" in str(w.message).lower()
|
||||
]
|
||||
self.assertEqual(
|
||||
dep,
|
||||
[],
|
||||
f"expected no Starlette httpx deprecation warning; got: "
|
||||
f"{[str(w.message) for w in dep]}",
|
||||
)
|
||||
finally:
|
||||
sys.modules.update(saved)
|
||||
|
||||
def test_canonical_webui_testclient_import(self) -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
from tests.webui_testclient import TestClient
|
||||
from webui.app import create_app
|
||||
|
||||
client = TestClient(create_app())
|
||||
response = client.get("/")
|
||||
self.assertIn(response.status_code, {200, 302, 404})
|
||||
|
||||
dep = [
|
||||
w
|
||||
for w in caught
|
||||
if "httpx" in str(w.message).lower()
|
||||
and "deprecated" in str(w.message).lower()
|
||||
]
|
||||
self.assertEqual(dep, [], [str(w.message) for w in dep])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Exact-owner renewal of an expired author issue lease (#760).
|
||||
|
||||
Covers the renewal disposition that lets the exact recorded owner re-acquire
|
||||
its own lock after the wall-clock lease expires — including while the recording
|
||||
MCP daemon PID is still alive — plus every rejection condition that must keep
|
||||
failing closed, and the pre-existing dead-PID and live-foreign dispositions
|
||||
that must remain untouched.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
|
||||
ISSUE = 5150
|
||||
BRANCH = f"fix/issue-{ISSUE}-demo"
|
||||
WORKTREE = "/scratch/wt-5150"
|
||||
HEAD = "c" * 40
|
||||
OTHER_SHA = "d" * 40
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "example-author"
|
||||
REMOTE = "prgs"
|
||||
ORG = "ExampleOrg"
|
||||
REPO = "ExampleRepo"
|
||||
|
||||
|
||||
def dead_pid() -> int:
|
||||
"""A PID that has certainly exited (spawned, then reaped)."""
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
return proc.pid
|
||||
|
||||
|
||||
def past_ts(hours: int = 1) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) - timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def future_ts(hours: int = 4) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def make_lock(*, expires_at: str | None = None, pid: int | None = None, **overrides):
|
||||
"""An expired lock owned by a still-alive daemon PID — the #760 condition."""
|
||||
lock = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
# os.getpid() is unambiguously alive: the whole point of #760 is that
|
||||
# daemon liveness is not evidence of an active author task.
|
||||
"session_pid": os.getpid() if pid is None else pid,
|
||||
"lock_generation": 3,
|
||||
"work_lease": {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"branch": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||
"created_at": past_ts(5),
|
||||
"expires_at": expires_at or past_ts(),
|
||||
},
|
||||
}
|
||||
lease_overrides = overrides.pop("work_lease", None)
|
||||
if lease_overrides:
|
||||
lock["work_lease"].update(lease_overrides)
|
||||
lock.update(overrides)
|
||||
return lock
|
||||
|
||||
|
||||
def assess(lock=None, **overrides):
|
||||
"""Run the assessor with all-passing evidence unless overridden."""
|
||||
kwargs = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": WORKTREE,
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"identity": IDENTITY,
|
||||
"profile": PROFILE,
|
||||
"current_branch": BRANCH,
|
||||
"porcelain_status": "",
|
||||
"worktree_exists": True,
|
||||
"head_sha": HEAD,
|
||||
"remote_head_sha": HEAD,
|
||||
"pr_head_sha": None,
|
||||
"pr_number": None,
|
||||
"competing_live_locks": [],
|
||||
"candidate_branches": [BRANCH],
|
||||
"current_pid": 4242,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return issue_lock_renewal.assess_exact_owner_lease_renewal(
|
||||
make_lock() if lock is None else lock, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class ExactOwnerRenewalGranted(unittest.TestCase):
|
||||
"""AC1/AC3-AC7: the positive path."""
|
||||
|
||||
def test_expired_lease_alive_pid_exact_owner_is_renewable(self):
|
||||
result = assess()
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.RENEWAL_SANCTIONED)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
self.assertTrue(result["is_candidate"])
|
||||
|
||||
def test_renewal_holds_when_owning_pr_head_matches(self):
|
||||
result = assess(pr_number=999, pr_head_sha=HEAD)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
def test_evidence_records_both_sides_of_the_transition(self):
|
||||
result = assess()
|
||||
evidence = result["evidence"]
|
||||
self.assertEqual(evidence["prior_pid"], os.getpid())
|
||||
self.assertTrue(evidence["prior_pid_alive"])
|
||||
self.assertEqual(evidence["replacement_pid"], 4242)
|
||||
self.assertTrue(evidence["prior_expires_at"])
|
||||
|
||||
|
||||
class ExactOwnerRenewalRefused(unittest.TestCase):
|
||||
"""AC3-AC8: every near-match must fail closed, one reason at a time."""
|
||||
|
||||
def _refused(self, **overrides):
|
||||
result = assess(**overrides)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
self.assertFalse(result["renewal_sanctioned"])
|
||||
self.assertTrue(result["reasons"])
|
||||
return result
|
||||
|
||||
def test_different_branch_refused(self):
|
||||
result = self._refused(branch_name=f"fix/issue-{ISSUE}-other")
|
||||
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_worktree_refused(self):
|
||||
result = self._refused(worktree_path="/scratch/somewhere-else")
|
||||
self.assertTrue(any("worktree" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_claimant_refused(self):
|
||||
result = self._refused(identity="someone-else")
|
||||
self.assertTrue(any("claimant" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_profile_refused(self):
|
||||
result = self._refused(profile="other-author")
|
||||
self.assertTrue(any("profile" in r for r in result["reasons"]))
|
||||
|
||||
def test_different_remote_org_or_repo_refused(self):
|
||||
self._refused(remote="dadeschools")
|
||||
self._refused(org="OtherOrg")
|
||||
self._refused(repo="OtherRepo")
|
||||
|
||||
def test_dirty_worktree_refused(self):
|
||||
result = self._refused(porcelain_status=" M gitea_mcp_server.py\n")
|
||||
self.assertTrue(any("uncommitted" in r for r in result["reasons"]))
|
||||
|
||||
def test_missing_worktree_refused(self):
|
||||
result = self._refused(worktree_exists=False)
|
||||
self.assertTrue(any("does not exist" in r for r in result["reasons"]))
|
||||
|
||||
def test_worktree_on_wrong_branch_refused(self):
|
||||
self._refused(current_branch="master")
|
||||
|
||||
def test_local_and_remote_head_mismatch_refused(self):
|
||||
result = self._refused(remote_head_sha=OTHER_SHA)
|
||||
self.assertTrue(
|
||||
any("does not equal remote head" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_unpublished_branch_refused(self):
|
||||
result = self._refused(remote_head_sha=None)
|
||||
self.assertTrue(any("remote branch head" in r for r in result["reasons"]))
|
||||
|
||||
def test_pr_head_mismatch_refused(self):
|
||||
result = self._refused(pr_number=999, pr_head_sha=OTHER_SHA)
|
||||
self.assertTrue(any("does not equal local" in r for r in result["reasons"]))
|
||||
|
||||
def test_unobservable_pr_head_refused(self):
|
||||
self._refused(pr_number=999, pr_head_sha=None)
|
||||
|
||||
def test_competing_live_lock_on_same_issue_refused(self):
|
||||
result = self._refused(
|
||||
competing_live_locks=[
|
||||
{"issue_number": ISSUE, "branch_name": BRANCH, "pid": 777}
|
||||
]
|
||||
)
|
||||
self.assertTrue(any("live lock" in r for r in result["reasons"]))
|
||||
|
||||
def test_competing_live_lock_holding_the_branch_refused(self):
|
||||
self._refused(
|
||||
competing_live_locks=[
|
||||
{"issue_number": 111, "branch_name": BRANCH, "worktree_path": ""}
|
||||
]
|
||||
)
|
||||
|
||||
def test_competing_branch_claim_refused(self):
|
||||
result = self._refused(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"])
|
||||
self.assertTrue(any("issue marker" in r for r in result["reasons"]))
|
||||
|
||||
def test_malformed_durable_lock_refused(self):
|
||||
lock = make_lock()
|
||||
lock["worktree_path"] = ""
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
|
||||
def test_lock_without_recorded_claimant_refused(self):
|
||||
lock = make_lock()
|
||||
lock["work_lease"]["claimant"] = {}
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
|
||||
|
||||
class NotARenewalCandidate(unittest.TestCase):
|
||||
"""AC12 and scope: situations renewal must decline to judge at all."""
|
||||
|
||||
def test_live_foreign_lease_is_never_a_candidate(self):
|
||||
lock = make_lock(expires_at=future_ts())
|
||||
result = assess(lock, identity="someone-else")
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
self.assertFalse(result["renewal_sanctioned"])
|
||||
|
||||
def test_unexpired_lease_is_never_a_candidate(self):
|
||||
lock = make_lock(expires_at=future_ts())
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_dead_pid_under_unexpired_lease_stays_with_753(self):
|
||||
"""The opposite trigger; #760 must not re-own it."""
|
||||
lock = make_lock(expires_at=future_ts(), pid=dead_pid())
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_absent_lock_is_not_a_candidate(self):
|
||||
result = assess({})
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_different_issue_is_not_a_candidate(self):
|
||||
lock = make_lock()
|
||||
lock["issue_number"] = ISSUE + 1
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
def test_different_operation_type_is_not_a_candidate(self):
|
||||
lock = make_lock()
|
||||
lock["work_lease"]["operation_type"] = "review_pr_work"
|
||||
result = assess(lock)
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.NO_CANDIDATE)
|
||||
|
||||
|
||||
class DaemonPidIsNotTaskLiveness(unittest.TestCase):
|
||||
"""AC16: a live recorded PID is never, by itself, authorization."""
|
||||
|
||||
def test_alive_pid_alone_does_not_authorize_renewal(self):
|
||||
# Every ownership fact except the live PID is wrong.
|
||||
result = assess(identity="someone-else", branch_name="fix/issue-1-nope")
|
||||
self.assertEqual(result["outcome"], issue_lock_renewal.REFUSED)
|
||||
self.assertTrue(result["evidence"]["prior_pid_alive"])
|
||||
|
||||
def test_renewal_does_not_require_a_dead_pid(self):
|
||||
result = assess()
|
||||
self.assertTrue(result["evidence"]["prior_pid_alive"])
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
def test_dead_pid_does_not_block_an_otherwise_exact_owner(self):
|
||||
lock = make_lock(pid=dead_pid())
|
||||
result = assess(lock)
|
||||
self.assertTrue(result["renewal_sanctioned"])
|
||||
|
||||
|
||||
class ConflictGateOrdering(unittest.TestCase):
|
||||
"""AC2: the same-owner allowance is reachable on an expired lease.
|
||||
|
||||
These cases need a worktree that genuinely exists on disk. The #601 reclaim
|
||||
affordance already permits takeover when the recorded worktree is missing,
|
||||
so a fictional path would satisfy the gate for the wrong reason and never
|
||||
exercise the ordering defect this issue is about.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._tmp = tempfile.TemporaryDirectory()
|
||||
cls.worktree = cls._tmp.name
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._tmp.cleanup()
|
||||
|
||||
def present_lock(self, **overrides):
|
||||
return make_lock(worktree_path=self.worktree, **overrides)
|
||||
|
||||
def test_expired_same_owner_is_allowed_when_renewal_is_sanctioned(self):
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(),
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
def test_expired_same_owner_still_blocks_without_the_waiver(self):
|
||||
"""Regression for the ordering defect: no waiver, no change in behavior.
|
||||
|
||||
Live PID and a present worktree, so the #601 reclaim affordance refuses;
|
||||
before #760 this was the permanent dead end for an exact owner.
|
||||
"""
|
||||
lock = self.present_lock()
|
||||
self.assertFalse(
|
||||
issue_lock_store.assess_expired_lock_reclaim(lock)["reclaim_allowed"]
|
||||
)
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
lock,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("Recovery review is required", block)
|
||||
|
||||
def test_waiver_does_not_unlock_a_different_owner(self):
|
||||
"""AC11: the waiver is scoped by same_owner, not merely by its own flag."""
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(),
|
||||
issue_number=ISSUE,
|
||||
branch_name=f"fix/issue-{ISSUE}-someone-else",
|
||||
worktree_path=self.worktree,
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("Recovery review is required", block)
|
||||
|
||||
def test_live_lease_disposition_is_unchanged(self):
|
||||
"""AC12: a live foreign lease still blocks, waiver or not."""
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
self.present_lock(expires_at=future_ts()),
|
||||
issue_number=ISSUE,
|
||||
branch_name=f"fix/issue-{ISSUE}-someone-else",
|
||||
worktree_path="/scratch/other",
|
||||
renewal_sanctioned=True,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("already has an active", block)
|
||||
|
||||
def test_dead_pid_reclaim_path_is_unchanged(self):
|
||||
"""AC11: expired + dead PID still reclaims through the #601 affordance."""
|
||||
lock = self.present_lock(pid=dead_pid())
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||
self.assertTrue(reclaim["reclaim_allowed"])
|
||||
block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
lock,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
|
||||
class RenewalRecordAndDownstream(unittest.TestCase):
|
||||
"""AC9/AC10: durable audit trail, and a renewed lock that actually works."""
|
||||
|
||||
def test_record_captures_prior_and_replacement_state(self):
|
||||
assessment = assess()
|
||||
record = issue_lock_renewal.build_renewal_record(
|
||||
assessment,
|
||||
renewed_at="2026-01-01T00:00:00Z",
|
||||
new_expires_at="2026-01-01T04:00:00Z",
|
||||
)
|
||||
self.assertTrue(record["renewed"])
|
||||
self.assertEqual(record["prior_pid"], os.getpid())
|
||||
self.assertEqual(record["new_expires_at"], "2026-01-01T04:00:00Z")
|
||||
self.assertEqual(record["renewed_at"], "2026-01-01T00:00:00Z")
|
||||
self.assertEqual(record["identity"], IDENTITY)
|
||||
self.assertEqual(record["profile"], PROFILE)
|
||||
self.assertTrue(record["prior_expires_at"])
|
||||
self.assertTrue(record["proof"])
|
||||
|
||||
def test_renewed_lock_satisfies_verify_lock_for_mutation(self):
|
||||
renewed = make_lock(expires_at=future_ts())
|
||||
renewed["session_pid"] = os.getpid()
|
||||
renewed["lease_renewal"] = {"renewed": True}
|
||||
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||
renewed,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
)
|
||||
self.assertTrue(verdict["proven"])
|
||||
self.assertFalse(verdict["block"])
|
||||
|
||||
def test_refusal_message_names_the_missing_evidence(self):
|
||||
assessment = assess(porcelain_status=" M gitea_mcp_server.py\n")
|
||||
message = issue_lock_renewal.format_renewal_refusal(assessment)
|
||||
self.assertIn("refused", message)
|
||||
self.assertIn("uncommitted", message)
|
||||
|
||||
|
||||
class NoCallerControlledRenewalFlag(unittest.TestCase):
|
||||
"""AC14: renewal eligibility is never declarable by a caller."""
|
||||
|
||||
def test_lock_issue_tool_exposes_no_renewal_parameter(self):
|
||||
import gitea_mcp_server
|
||||
|
||||
target = gitea_mcp_server.gitea_lock_issue
|
||||
target = getattr(target, "fn", getattr(target, "__wrapped__", target))
|
||||
params = set(inspect.signature(target).parameters)
|
||||
for forbidden in ("renewal_sanctioned", "renew", "allow_renewal", "is_owner"):
|
||||
self.assertNotIn(forbidden, params)
|
||||
|
||||
def test_store_defaults_to_no_waiver(self):
|
||||
params = inspect.signature(
|
||||
issue_lock_store.assess_same_issue_lease_conflict
|
||||
).parameters
|
||||
self.assertIs(params["renewal_sanctioned"].default, False)
|
||||
bind_params = inspect.signature(issue_lock_store.bind_session_lock).parameters
|
||||
self.assertIs(bind_params["renewal_sanctioned"].default, False)
|
||||
|
||||
|
||||
class NoIssueNumberSpecialCasing(unittest.TestCase):
|
||||
"""AC17: no repository issue or PR number is special-cased."""
|
||||
|
||||
def test_module_contains_no_hardcoded_issue_special_cases(self):
|
||||
source = inspect.getsource(issue_lock_renewal)
|
||||
code = "\n".join(
|
||||
line for line in source.splitlines() if not line.strip().startswith("#")
|
||||
)
|
||||
for literal in ("757", "759", "760"):
|
||||
self.assertNotIn(f"== {literal}", code)
|
||||
self.assertNotIn(f"issue_number == {literal}", code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,342 @@
|
||||
"""MCP-level exact-owner lease renewal through ``gitea_lock_issue`` (#760).
|
||||
|
||||
The unit suite in ``test_issue_760_exact_owner_lease_renewal`` proves the
|
||||
renewal *disposition*. It cannot prove the disposition survives the rest of the
|
||||
tool, and it did not: the waiver was computed and then discarded before
|
||||
``assess_issue_lock_worktree``, so every real renewal still failed on
|
||||
base-equivalence. A branch being renewed always carries committed work, so it is
|
||||
never base-equivalent by construction — exactly the argument #753 already makes
|
||||
for recovery.
|
||||
|
||||
These tests drive the public tool end to end against a real git repository and a
|
||||
real durable lock file, composing every gate in the production order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
|
||||
ISSUE = 9760
|
||||
BRANCH = f"fix/issue-{ISSUE}-renewal-mcp"
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
|
||||
def _past_ts(hours: int = 1) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) - timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _RenewalMcpBase(unittest.TestCase):
|
||||
"""Real git repo + durable expired lock owned by a live PID.
|
||||
|
||||
The recorded PID is ``os.getpid()`` — unambiguously alive. That is the whole
|
||||
point of #760: the PID belongs to the long-lived MCP daemon, so its liveness
|
||||
says nothing about whether the authoring task still holds the work.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.repo = tempfile.mkdtemp(prefix="issue760-mcp-")
|
||||
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
|
||||
self._init_worktree()
|
||||
self.remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||
)
|
||||
self.remotes.start()
|
||||
self.addCleanup(patch.stopall)
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def _git(self, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", self.repo, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _init_worktree(self):
|
||||
self._git("init", "-q", "-b", "master")
|
||||
self._git("config", "user.email", "[email protected]")
|
||||
self._git("config", "user.name", "Test")
|
||||
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
|
||||
fh.write("seed\n")
|
||||
self._git("add", "seed.txt")
|
||||
self._git("commit", "-q", "-m", "seed")
|
||||
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
# The branch carries committed work, so it is NOT base-equivalent.
|
||||
self._git("checkout", "-q", "-b", BRANCH)
|
||||
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
|
||||
fh.write("author work\n")
|
||||
self._git("add", "work.txt")
|
||||
self._git("commit", "-q", "-m", "author work")
|
||||
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self.worktree = os.path.realpath(self.repo)
|
||||
|
||||
def write_expired_lock(self, **overrides):
|
||||
path = issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
lock_dir=self.lock_dir.name,
|
||||
)
|
||||
claimant = {"username": IDENTITY, "profile": PROFILE}
|
||||
pid = overrides.pop("session_pid", os.getpid())
|
||||
overrides.pop("pid", None)
|
||||
lease_overrides = overrides.pop("work_lease", {})
|
||||
data = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": self.worktree,
|
||||
"session_pid": pid,
|
||||
"pid": pid,
|
||||
"lock_generation": 3,
|
||||
"work_lease": {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": BRANCH,
|
||||
"worktree_path": self.worktree,
|
||||
"claimant": claimant,
|
||||
"created_at": _past_ts(5),
|
||||
"last_heartbeat_at": _past_ts(5),
|
||||
"expires_at": _past_ts(), # already expired
|
||||
},
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=claimant,
|
||||
),
|
||||
}
|
||||
data["work_lease"].update(lease_overrides)
|
||||
data.update(overrides)
|
||||
data["session_pid"] = pid
|
||||
data["pid"] = pid
|
||||
data["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, data)
|
||||
return path
|
||||
|
||||
def _tool_env(self):
|
||||
env = shared_mutation_env(
|
||||
PROFILE,
|
||||
include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||
)
|
||||
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return env
|
||||
|
||||
def _git_state(self, *, porcelain="", branch=BRANCH, head=None):
|
||||
return {
|
||||
"current_branch": branch,
|
||||
"porcelain_status": porcelain,
|
||||
# The decisive fact: a branch carrying work is never base-equivalent.
|
||||
"base_equivalent": False,
|
||||
"head_sha": head or self.head_sha,
|
||||
"inspected_git_root": self.worktree,
|
||||
"base_branch": "master",
|
||||
}
|
||||
|
||||
def run_lock_issue(
|
||||
self,
|
||||
*,
|
||||
branch_entries=None,
|
||||
open_prs=None,
|
||||
git_state=None,
|
||||
identity=IDENTITY,
|
||||
profile=PROFILE,
|
||||
):
|
||||
"""Drive the public tool for the published exact-owner renewal shape."""
|
||||
if branch_entries is None:
|
||||
branch_entries = [{"name": BRANCH, "commit": {"id": self.head_sha}}]
|
||||
if open_prs is None:
|
||||
open_prs = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
|
||||
if git_state is None:
|
||||
git_state = self._git_state()
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server.api_get_all", return_value=list(branch_entries)
|
||||
), patch(
|
||||
"mcp_server._list_open_pulls", return_value=list(open_prs)
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": identity, "profile": profile},
|
||||
), patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=git_state,
|
||||
), patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=lambda h, o, r, auth, issue_number: (
|
||||
list(open_prs),
|
||||
[b.get("name") for b in branch_entries if isinstance(b, dict)],
|
||||
{"status": "not_claimed"},
|
||||
),
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_lock_issue(
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
remote="prgs",
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
|
||||
|
||||
class TestRenewalReachableThroughTool(_RenewalMcpBase):
|
||||
"""F1: the sanctioned renewal must survive every downstream gate."""
|
||||
|
||||
def test_expired_lease_live_pid_exact_owner_renews_through_the_tool(self):
|
||||
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
|
||||
self.assertTrue(issue_lock_store.is_lease_expired(prior))
|
||||
self.assertTrue(issue_lock_store.is_process_alive(prior["session_pid"]))
|
||||
|
||||
result = self.run_lock_issue()
|
||||
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(result["issue_number"], ISSUE)
|
||||
self.assertEqual(result["branch_name"], BRANCH)
|
||||
# The renewal is reported natively, so no lock-file inspection is needed.
|
||||
self.assertIn("lease_renewal", result)
|
||||
self.assertTrue(result["lease_renewal"]["renewed"])
|
||||
self.assertIn("Renewed the expired", result["message"])
|
||||
|
||||
def test_renewed_lock_records_prior_and_replacement_evidence(self):
|
||||
prior = issue_lock_store.read_lock_file(self.write_expired_lock())
|
||||
prior_expiry = prior["work_lease"]["expires_at"]
|
||||
prior_generation = issue_lock_store.lock_generation(prior)
|
||||
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
|
||||
renewal = written["lease_renewal"]
|
||||
self.assertTrue(renewal["renewed"])
|
||||
self.assertEqual(renewal["prior_pid"], prior["session_pid"])
|
||||
self.assertTrue(renewal["prior_pid_alive"])
|
||||
self.assertEqual(renewal["prior_expires_at"], prior_expiry)
|
||||
self.assertEqual(renewal["identity"], IDENTITY)
|
||||
self.assertEqual(renewal["profile"], PROFILE)
|
||||
self.assertEqual(renewal["head_sha"], self.head_sha)
|
||||
self.assertTrue(renewal["proof"])
|
||||
# New expiry is a fresh absolute stamp, later than the one it replaced.
|
||||
self.assertEqual(renewal["new_expires_at"], written["work_lease"]["expires_at"])
|
||||
self.assertGreater(renewal["new_expires_at"], prior_expiry)
|
||||
# Compare-and-swap advanced the generation exactly once.
|
||||
self.assertEqual(
|
||||
issue_lock_store.lock_generation(written), prior_generation + 1
|
||||
)
|
||||
|
||||
def test_renewed_lock_is_live_and_satisfies_mutation_ownership(self):
|
||||
self.write_expired_lock()
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
|
||||
self.assertTrue(issue_lock_store.assess_lock_freshness(written)["live"])
|
||||
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||
written,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertTrue(verdict["proven"], verdict)
|
||||
self.assertFalse(verdict["block"])
|
||||
|
||||
def test_recovery_record_is_not_written_for_a_live_owner_renewal(self):
|
||||
"""#753 recovery must not be claimed when the recorded PID is alive."""
|
||||
self.write_expired_lock()
|
||||
result = self.run_lock_issue()
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
self.assertNotIn("dead_session_recovery", written)
|
||||
|
||||
|
||||
class TestRenewalWaiverIsNarrow(_RenewalMcpBase):
|
||||
"""The waiver relaxes base-equivalence and nothing else."""
|
||||
|
||||
def test_dirty_worktree_still_blocks_a_would_be_renewal(self):
|
||||
"""Cleanliness is never waived; the renewal assessor refuses first.
|
||||
|
||||
A dirty worktree makes the renewal refuse, so no waiver is issued and
|
||||
the lease-conflict gate fails closed ahead of the worktree gate. The
|
||||
refusal names the uncommitted files, so the owner still learns why.
|
||||
"""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(
|
||||
git_state=self._git_state(porcelain=" M gitea_mcp_server.py\n")
|
||||
)
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Recovery review is required before takeover", message)
|
||||
self.assertIn("worktree has uncommitted tracked changes", message)
|
||||
self.assertIn("gitea_mcp_server.py", message)
|
||||
|
||||
def test_foreign_claimant_cannot_use_the_waiver(self):
|
||||
"""A near-match owner gets no renewal and no base-equivalence waiver."""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(identity="someone-else")
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Recovery review is required before takeover", message)
|
||||
# The refusal names the missing ownership evidence (#760 diagnostics).
|
||||
self.assertIn("does not match active identity", message)
|
||||
|
||||
def test_foreign_profile_cannot_use_the_waiver(self):
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(profile="other-author")
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_unpublished_branch_cannot_use_the_waiver(self):
|
||||
"""No remote head to agree with, so exact-owner renewal is refused."""
|
||||
self.write_expired_lock()
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(branch_entries=[], open_prs=[])
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_pr_head_mismatch_cannot_use_the_waiver(self):
|
||||
self.write_expired_lock()
|
||||
other = "9" * 40
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue(
|
||||
open_prs=[{"number": 4242, "head": {"ref": BRANCH, "sha": other}}]
|
||||
)
|
||||
self.assertIn(
|
||||
"Recovery review is required before takeover", str(ctx.exception)
|
||||
)
|
||||
|
||||
def test_non_base_equivalent_branch_still_blocks_without_any_waiver(self):
|
||||
"""No durable lock at all: the ordinary base-equivalence rule applies."""
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
self.run_lock_issue()
|
||||
self.assertIn("must be base-equivalent", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -871,9 +871,13 @@ class TestAc6McpUnpublishedClaimRecovery(_UnpublishedMcpBase):
|
||||
save_calls: list[dict] = []
|
||||
real_save = mcp_server._save_issue_lock
|
||||
|
||||
def tracking_save(data, *, expected_generation=None):
|
||||
def tracking_save(data, *, expected_generation=None, renewal_sanctioned=False):
|
||||
save_calls.append({"expected_generation": expected_generation, "data": dict(data)})
|
||||
return real_save(data, expected_generation=expected_generation)
|
||||
return real_save(
|
||||
data,
|
||||
expected_generation=expected_generation,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
|
||||
with patch("mcp_server._save_issue_lock", side_effect=tracking_save):
|
||||
result = self.run_lock_issue()
|
||||
@@ -926,18 +930,33 @@ class TestAc6McpUnpublishedClaimRecovery(_UnpublishedMcpBase):
|
||||
real_bind = issue_lock_store.bind_session_lock
|
||||
bind_calls: list[int | None] = []
|
||||
|
||||
def racing_bind(data, lock_dir=None, expected_generation=None):
|
||||
# #760 added the renewal waiver keyword; the double forwards it verbatim
|
||||
# so this race still exercises the real compare-and-swap.
|
||||
def racing_bind(
|
||||
data, lock_dir=None, expected_generation=None, renewal_sanctioned=False
|
||||
):
|
||||
bind_calls.append(expected_generation)
|
||||
if expected_generation is None:
|
||||
return real_bind(data, lock_dir=lock_dir, expected_generation=None)
|
||||
return real_bind(
|
||||
data,
|
||||
lock_dir=lock_dir,
|
||||
expected_generation=None,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
# First concurrent writer wins.
|
||||
if len([c for c in bind_calls if c is not None]) == 1:
|
||||
return real_bind(
|
||||
data, lock_dir=lock_dir, expected_generation=expected_generation
|
||||
data,
|
||||
lock_dir=lock_dir,
|
||||
expected_generation=expected_generation,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
# Second concurrent writer still holds the pre-race generation.
|
||||
return real_bind(
|
||||
data, lock_dir=lock_dir, expected_generation=expected_generation
|
||||
data,
|
||||
lock_dir=lock_dir,
|
||||
expected_generation=expected_generation,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
|
||||
# First recovery succeeds and advances generation.
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Task heartbeat through the native MCP author path (#790 Slice A, AC-N6).
|
||||
|
||||
Assessor-level coverage is not sufficient here, and this project has already
|
||||
paid for learning that: in review #499 on PR #791 the #760 renewal waiver was
|
||||
computed correctly and then *discarded* at two later gates, so every real
|
||||
renewal still failed while the unit suite stayed green. AC-N6 exists because of
|
||||
that, and requires driving the real tools against a real git repository and a
|
||||
real durable lock file, composing the gates in production order.
|
||||
|
||||
These tests therefore call ``gitea_lock_issue`` and
|
||||
``gitea_heartbeat_issue_lock`` themselves and assert on what lands on disk,
|
||||
never on an assessor's return value alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import lease_policy # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
|
||||
ISSUE = 9791
|
||||
BRANCH = f"fix/issue-{ISSUE}-heartbeat-mcp"
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
|
||||
def _ts(moment: datetime) -> str:
|
||||
return (
|
||||
moment.astimezone(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _HeartbeatMcpBase(unittest.TestCase):
|
||||
"""Real git repo plus a real durable lock, driven through the real tools."""
|
||||
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.repo = tempfile.mkdtemp(prefix="issue790-mcp-")
|
||||
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
|
||||
self._init_worktree()
|
||||
self.remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||
)
|
||||
self.remotes.start()
|
||||
self.addCleanup(patch.stopall)
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def _git(self, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", self.repo, *args], capture_output=True, text=True, check=True
|
||||
)
|
||||
|
||||
def _init_worktree(self):
|
||||
self._git("init", "-q", "-b", "master")
|
||||
self._git("config", "user.email", "[email protected]")
|
||||
self._git("config", "user.name", "Test")
|
||||
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
|
||||
fh.write("seed\n")
|
||||
self._git("add", "seed.txt")
|
||||
self._git("commit", "-q", "-m", "seed")
|
||||
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
# A fresh claim starts base-equivalent, which is the ordinary first-lock
|
||||
# shape and exercises assess_issue_lock_worktree on its normal path.
|
||||
self._git("checkout", "-q", "-b", BRANCH)
|
||||
self.head_sha = self.base_sha
|
||||
self.worktree = os.path.realpath(self.repo)
|
||||
|
||||
def _lock_path(self):
|
||||
return issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
lock_dir=self.lock_dir.name,
|
||||
)
|
||||
|
||||
def _tool_env(self):
|
||||
env = shared_mutation_env(
|
||||
PROFILE, include_example_repo=True, GITEA_ISSUE_LOCK_DIR=self.lock_dir.name
|
||||
)
|
||||
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return env
|
||||
|
||||
def _git_state(self, *, porcelain="", base_equivalent=True):
|
||||
return {
|
||||
"current_branch": BRANCH,
|
||||
"porcelain_status": porcelain,
|
||||
"base_equivalent": base_equivalent,
|
||||
"head_sha": self.head_sha,
|
||||
"inspected_git_root": self.worktree,
|
||||
"base_branch": "master",
|
||||
}
|
||||
|
||||
def run_lock_issue(
|
||||
self,
|
||||
*,
|
||||
branch_entries=None,
|
||||
open_prs=None,
|
||||
git_state=None,
|
||||
identity=IDENTITY,
|
||||
profile=PROFILE,
|
||||
):
|
||||
branch_entries = branch_entries if branch_entries is not None else []
|
||||
open_prs = open_prs if open_prs is not None else []
|
||||
git_state = git_state or self._git_state()
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server.api_get_all", return_value=list(branch_entries)
|
||||
), patch(
|
||||
"mcp_server._list_open_pulls", return_value=list(open_prs)
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": identity, "profile": profile},
|
||||
), patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=git_state,
|
||||
), patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=lambda h, o, r, auth, issue_number: (
|
||||
list(open_prs),
|
||||
[b.get("name") for b in branch_entries if isinstance(b, dict)],
|
||||
{"status": "not_claimed"},
|
||||
),
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_lock_issue(
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
remote="prgs",
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
|
||||
def run_heartbeat(
|
||||
self, *, task_session_id, identity=IDENTITY, profile=PROFILE, **kwargs
|
||||
):
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": identity, "profile": profile},
|
||||
), patch("mcp_server.get_auth_header", return_value="token x"), patch.dict(
|
||||
os.environ, env, clear=True
|
||||
):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_heartbeat_issue_lock(
|
||||
issue_number=ISSUE,
|
||||
branch_name=kwargs.pop("branch_name", BRANCH),
|
||||
task_session_id=task_session_id,
|
||||
remote="prgs",
|
||||
worktree_path=kwargs.pop("worktree_path", self.worktree),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def write_legacy_lock(self, *, hours_old: float = 3.0, ttl_hours: float = 4.0):
|
||||
"""A durable lock in the shape the store wrote before this slice."""
|
||||
now = datetime.now(timezone.utc)
|
||||
claimant = {"username": IDENTITY, "profile": PROFILE}
|
||||
created = now - timedelta(hours=hours_old)
|
||||
record = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": self.worktree,
|
||||
"session_pid": os.getpid(),
|
||||
"pid": os.getpid(),
|
||||
"lock_generation": 1,
|
||||
"work_lease": {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": BRANCH,
|
||||
"worktree_path": self.worktree,
|
||||
"claimant": claimant,
|
||||
"created_at": _ts(created),
|
||||
# The legacy signature: never advanced past creation.
|
||||
"last_heartbeat_at": _ts(created),
|
||||
"expires_at": _ts(created + timedelta(hours=ttl_hours)),
|
||||
},
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue", claimant=claimant
|
||||
),
|
||||
}
|
||||
path = self._lock_path()
|
||||
record["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, record)
|
||||
return record
|
||||
|
||||
|
||||
class TestLockIssueMintsTheLifecycle(_HeartbeatMcpBase):
|
||||
"""Durable lock creation and read-back through the real tool."""
|
||||
|
||||
def test_native_lock_writes_the_marker_and_a_task_session_id(self):
|
||||
result = self.run_lock_issue()
|
||||
self.assertTrue(result["success"], result)
|
||||
|
||||
written = issue_lock_store.read_lock_file(result["lock_file_path"])
|
||||
lease = written["work_lease"]
|
||||
self.assertEqual(
|
||||
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
|
||||
)
|
||||
self.assertTrue(lease["task_session_id"])
|
||||
self.assertFalse(issue_lock_store.is_legacy_lease(written))
|
||||
# AC-N1: the ownership key is not the daemon pid, which is recorded
|
||||
# separately as evidence.
|
||||
self.assertNotIn(str(written["session_pid"]), lease["task_session_id"])
|
||||
self.assertEqual(written["session_pid"], os.getpid())
|
||||
|
||||
def test_native_lease_uses_the_policy_window_not_four_hours(self):
|
||||
result = self.run_lock_issue()
|
||||
lease = result["work_lease"]
|
||||
created = datetime.fromisoformat(lease["created_at"].replace("Z", "+00:00"))
|
||||
expires = datetime.fromisoformat(lease["expires_at"].replace("Z", "+00:00"))
|
||||
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
|
||||
self.assertEqual(
|
||||
(expires - created).total_seconds() / 60.0, policy.initial_ttl_minutes
|
||||
)
|
||||
|
||||
def test_freshness_of_a_new_native_lock_is_live(self):
|
||||
result = self.run_lock_issue()
|
||||
self.assertEqual(
|
||||
result["lock_freshness"]["status"], issue_lock_store.STATUS_LIVE
|
||||
)
|
||||
self.assertTrue(result["lock_freshness"]["live"])
|
||||
|
||||
|
||||
class TestHeartbeatThroughTheTool(_HeartbeatMcpBase):
|
||||
def _lock_and_session(self):
|
||||
result = self.run_lock_issue()
|
||||
self.assertTrue(result["success"], result)
|
||||
return result, result["work_lease"]["task_session_id"]
|
||||
|
||||
def test_heartbeat_slides_the_lease_and_advances_the_generation(self):
|
||||
locked, session = self._lock_and_session()
|
||||
before = issue_lock_store.read_lock_file(locked["lock_file_path"])
|
||||
|
||||
beat = self.run_heartbeat(task_session_id=session)
|
||||
|
||||
self.assertTrue(beat["success"], beat)
|
||||
self.assertEqual(beat["operation"], "heartbeat")
|
||||
after = issue_lock_store.read_lock_file(locked["lock_file_path"])
|
||||
self.assertGreater(
|
||||
issue_lock_store.lock_generation(after),
|
||||
issue_lock_store.lock_generation(before),
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
after["work_lease"]["expires_at"], before["work_lease"]["expires_at"]
|
||||
)
|
||||
self.assertEqual(after["work_lease"]["heartbeat_count"], 2)
|
||||
|
||||
def test_heartbeat_evidence_survives_the_downstream_mutation_gate(self):
|
||||
"""The #499 F2 lesson, applied.
|
||||
|
||||
A sanction that is computed and then discarded downstream is worthless.
|
||||
After a heartbeat the lock must still satisfy the gate every author
|
||||
mutation runs through.
|
||||
"""
|
||||
locked, session = self._lock_and_session()
|
||||
self.run_heartbeat(task_session_id=session)
|
||||
|
||||
written = issue_lock_store.read_lock_file(locked["lock_file_path"])
|
||||
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||
written,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
)
|
||||
self.assertTrue(verdict["proven"], verdict)
|
||||
self.assertFalse(verdict["block"])
|
||||
|
||||
def _duplicate_gate(self, *, open_prs, branches):
|
||||
env = self._tool_env()
|
||||
with patch("mcp_server.get_auth_header", return_value="token x"), patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=lambda h, o, r, auth, issue_number: (
|
||||
list(open_prs),
|
||||
list(branches),
|
||||
{"status": "not_claimed"},
|
||||
),
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_assess_work_issue_duplicate(
|
||||
issue_number=ISSUE, branch_name=BRANCH, remote="prgs"
|
||||
)
|
||||
|
||||
def test_heartbeat_does_not_change_the_duplicate_gate_verdict(self):
|
||||
"""The gate must be invariant under heartbeating.
|
||||
|
||||
The point is not that the gate passes — with a linked open PR at the
|
||||
lock phase it correctly blocks (#400), heartbeat or not. The property
|
||||
that matters is that sliding a lease neither loosens the gate nor
|
||||
corrupts the lock state it reads: the verdict before and after a
|
||||
heartbeat must be identical, for both the clear and the blocking shape.
|
||||
"""
|
||||
_, session = self._lock_and_session()
|
||||
linked = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
|
||||
|
||||
clear_before = self._duplicate_gate(open_prs=[], branches=[])
|
||||
blocked_before = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
|
||||
|
||||
self.assertTrue(self.run_heartbeat(task_session_id=session)["success"])
|
||||
|
||||
clear_after = self._duplicate_gate(open_prs=[], branches=[])
|
||||
blocked_after = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
|
||||
|
||||
self.assertEqual(clear_before["outcome"], clear_after["outcome"])
|
||||
self.assertFalse(clear_after["block"])
|
||||
self.assertEqual(blocked_before["outcome"], blocked_after["outcome"])
|
||||
self.assertTrue(blocked_after["block"])
|
||||
self.assertEqual(blocked_after["linked_open_pr"], 4242)
|
||||
|
||||
def test_foreign_session_id_is_refused_through_the_tool(self):
|
||||
self._lock_and_session()
|
||||
beat = self.run_heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
|
||||
self.assertFalse(beat["success"])
|
||||
self.assertIn("task_session_id does not match", " ".join(beat["reasons"]))
|
||||
|
||||
def test_stale_generation_is_refused_through_the_tool(self):
|
||||
locked, session = self._lock_and_session()
|
||||
current = issue_lock_store.lock_generation(
|
||||
issue_lock_store.read_lock_file(locked["lock_file_path"])
|
||||
)
|
||||
beat = self.run_heartbeat(
|
||||
task_session_id=session, expected_generation=current + 5
|
||||
)
|
||||
self.assertFalse(beat["success"])
|
||||
self.assertIn("generation changed", beat["reasons"][0])
|
||||
|
||||
def test_foreign_claimant_is_refused_through_the_tool(self):
|
||||
_, session = self._lock_and_session()
|
||||
beat = self.run_heartbeat(task_session_id=session, identity="someone-else")
|
||||
self.assertFalse(beat["success"])
|
||||
|
||||
def test_heartbeat_cannot_acquire_a_missing_lock(self):
|
||||
beat = self.run_heartbeat(task_session_id="author_issue_work-000000000000")
|
||||
self.assertFalse(beat["success"])
|
||||
self.assertIn("no durable lock", beat["reasons"][0])
|
||||
|
||||
def test_alive_pid_alone_does_not_keep_a_lease_live_through_the_tool(self):
|
||||
"""PID-only refusal, end to end.
|
||||
|
||||
The recorded pid is this live process. The lock is aged past its grace
|
||||
with no heartbeat, so the tool must refuse to slide it and the durable
|
||||
record must classify as a missed heartbeat rather than as live.
|
||||
"""
|
||||
locked, session = self._lock_and_session()
|
||||
record = issue_lock_store.read_lock_file(locked["lock_file_path"])
|
||||
record["work_lease"]["last_heartbeat_at"] = _ts(
|
||||
datetime.now(timezone.utc) - timedelta(minutes=30)
|
||||
)
|
||||
record["work_lease"]["expires_at"] = _ts(
|
||||
datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
)
|
||||
issue_lock_store.save_lock_file(locked["lock_file_path"], record)
|
||||
|
||||
self.assertTrue(issue_lock_store.is_process_alive(record["session_pid"]))
|
||||
fresh = issue_lock_store.assess_lock_freshness(record)
|
||||
self.assertEqual(
|
||||
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
|
||||
)
|
||||
self.assertTrue(fresh["pid_alive"])
|
||||
|
||||
beat = self.run_heartbeat(task_session_id=session)
|
||||
self.assertFalse(beat["success"])
|
||||
self.assertIn("reclaimed", " ".join(beat["reasons"]))
|
||||
|
||||
|
||||
class TestLegacyLocksThroughTheTool(_HeartbeatMcpBase):
|
||||
"""AC-N8 end to end: protected on deployment, and rebindable."""
|
||||
|
||||
def test_legacy_lock_stays_protected_after_deployment(self):
|
||||
record = self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
|
||||
self.assertTrue(fresh["legacy_lease"])
|
||||
self.assertTrue(fresh["legacy_expiry_preserved"])
|
||||
# It had never heartbeated, so under the new grace alone it would be
|
||||
# long gone; the preserved absolute expiry is what protects it.
|
||||
self.assertEqual(
|
||||
record["work_lease"]["created_at"],
|
||||
record["work_lease"]["last_heartbeat_at"],
|
||||
)
|
||||
|
||||
def test_tool_rebinds_a_legacy_lock_and_mints_a_first_heartbeat(self):
|
||||
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
|
||||
|
||||
result = self.run_heartbeat(task_session_id=None)
|
||||
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(result["operation"], "legacy_rebind")
|
||||
self.assertTrue(result["task_session_id"])
|
||||
|
||||
written = issue_lock_store.read_lock_file(self._lock_path())
|
||||
lease = written["work_lease"]
|
||||
self.assertEqual(
|
||||
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
|
||||
)
|
||||
self.assertEqual(lease["heartbeat_count"], 1)
|
||||
self.assertNotEqual(
|
||||
lease["created_at"],
|
||||
written["legacy_rebind"]["legacy_origin"]["created_at"],
|
||||
)
|
||||
self.assertFalse(issue_lock_store.is_legacy_lease(written))
|
||||
|
||||
def test_rebound_lock_then_heartbeats_through_the_tool(self):
|
||||
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
|
||||
rebound = self.run_heartbeat(task_session_id=None)
|
||||
beat = self.run_heartbeat(task_session_id=rebound["task_session_id"])
|
||||
self.assertTrue(beat["success"], beat)
|
||||
self.assertEqual(beat["operation"], "heartbeat")
|
||||
self.assertEqual(beat["heartbeat_count"], 2)
|
||||
|
||||
def test_rebind_refuses_a_foreign_owner_through_the_tool(self):
|
||||
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
|
||||
result = self.run_heartbeat(task_session_id=None, identity="someone-else")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["operation"], "legacy_rebind")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,594 @@
|
||||
"""Central lease policy and load-bearing heartbeat freshness (#790 Slice A).
|
||||
|
||||
Before this slice, ``issue_lock_store.assess_lock_freshness`` parsed
|
||||
``last_heartbeat_at`` and then never consulted it: liveness was decided by an
|
||||
absolute four-hour ``expires_at`` and by PID liveness. Because the recorded PID
|
||||
is the long-lived MCP daemon rather than the authoring task, an abandoned claim
|
||||
stayed "live" for the full four hours, and a claim whose work had already landed
|
||||
blocked reconciliation for just as long (Issue #787 / PR #789, and again Issue
|
||||
#760 / PR #791).
|
||||
|
||||
These tests pin the corrected semantics, including the two asymmetries that are
|
||||
easy to lose in a refactor:
|
||||
|
||||
* an **alive** PID must never make anything live (AC-N2), while
|
||||
* a **dead** PID must still mark a lease stale, because #753 dead-session
|
||||
recovery keys on exactly that classification.
|
||||
|
||||
Durable-state helpers here write real lock files through the real flock path;
|
||||
they are not mocks of the store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import issue_lock_store # noqa: E402
|
||||
import lease_policy # noqa: E402
|
||||
import pr_work_lease # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
|
||||
ISSUE = 9790
|
||||
BRANCH = f"fix/issue-{ISSUE}-heartbeat"
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Example-Org"
|
||||
REPO = "Example-Repo"
|
||||
REMOTE = "prgs"
|
||||
DEAD_PID = 2**22 # far above any live pid on a test host
|
||||
|
||||
|
||||
def _ts(moment: datetime) -> str:
|
||||
return (
|
||||
moment.astimezone(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _LockFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.now = datetime.now(timezone.utc)
|
||||
self.worktree = os.path.realpath(tempfile.mkdtemp(prefix="issue790-"))
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _path(self):
|
||||
return issue_lock_store.lock_file_path(
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
lock_dir=self.lock_dir.name,
|
||||
)
|
||||
|
||||
def write_lock(
|
||||
self,
|
||||
*,
|
||||
lifecycle: str | None = lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||
created_delta: timedelta = timedelta(minutes=1),
|
||||
heartbeat_delta: timedelta = timedelta(minutes=1),
|
||||
expires_delta: timedelta = timedelta(minutes=9),
|
||||
pid: int | None = None,
|
||||
task_session_id: str | None = "author_issue_work-aaaabbbbccccdddd",
|
||||
generation: int = 1,
|
||||
identity: str = IDENTITY,
|
||||
profile: str = PROFILE,
|
||||
branch: str = BRANCH,
|
||||
worktree: str | None = None,
|
||||
) -> dict:
|
||||
"""Write a real durable lock and return the record.
|
||||
|
||||
Deltas are relative to ``self.now``; ``expires_delta`` is added, the
|
||||
others subtracted, so "in the past" reads naturally at each call site.
|
||||
"""
|
||||
lease: dict = {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": branch,
|
||||
"worktree_path": worktree or self.worktree,
|
||||
"claimant": {"username": identity, "profile": profile},
|
||||
"created_at": _ts(self.now - created_delta),
|
||||
"last_heartbeat_at": _ts(self.now - heartbeat_delta),
|
||||
"expires_at": _ts(self.now + expires_delta),
|
||||
}
|
||||
if lifecycle is not None:
|
||||
lease["lifecycle_version"] = lifecycle
|
||||
if task_session_id is not None:
|
||||
lease["task_session_id"] = task_session_id
|
||||
pid_value = os.getpid() if pid is None else pid
|
||||
record = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": branch,
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": worktree or self.worktree,
|
||||
"session_pid": pid_value,
|
||||
"pid": pid_value,
|
||||
"lock_generation": generation,
|
||||
"work_lease": lease,
|
||||
}
|
||||
path = self._path()
|
||||
record["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, record)
|
||||
return record
|
||||
|
||||
|
||||
class TestPolicyIsTheSingleSource(unittest.TestCase):
|
||||
"""AC-N7: one authoritative configuration source for every duration."""
|
||||
|
||||
def test_author_policy_carries_the_agreed_values(self):
|
||||
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
|
||||
self.assertEqual(policy.initial_ttl_minutes, 10.0)
|
||||
self.assertEqual(policy.heartbeat_cadence_minutes, 2.0)
|
||||
self.assertEqual(policy.stale_warning_minutes, 5.0)
|
||||
self.assertEqual(policy.missed_heartbeat_grace_minutes, 10.0)
|
||||
self.assertEqual(policy.absolute_cap_hours, 8.0)
|
||||
self.assertEqual(policy.recovery_grace_minutes, 10.0)
|
||||
self.assertEqual(policy.terminal_race_drain_minutes, 2.0)
|
||||
self.assertTrue(policy.terminal_retirement_eligible)
|
||||
self.assertTrue(policy.heartbeat_lifecycle_active)
|
||||
|
||||
def test_the_four_hour_author_ttl_literal_is_gone(self):
|
||||
"""The duplicated literal AC-N7 exists to remove."""
|
||||
self.assertFalse(hasattr(issue_lock_store, "WORK_LEASE_TTL_HOURS"))
|
||||
import gitea_mcp_server
|
||||
|
||||
self.assertFalse(hasattr(gitea_mcp_server, "WORK_LEASE_TTL_HOURS"))
|
||||
|
||||
def test_declared_reviewer_values_match_the_module_still_using_them(self):
|
||||
"""Slice A declares reviewer/merger numbers without rewiring them.
|
||||
|
||||
Recording a value in two places is only safe if drift is detectable, so
|
||||
this asserts the declaration still equals the constants #747 owns. When
|
||||
Slice C migrates those call sites, this test becomes the proof the
|
||||
migration changed nothing.
|
||||
"""
|
||||
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_REVIEWER_PR)
|
||||
self.assertEqual(
|
||||
policy.initial_ttl_minutes, float(reviewer_pr_lease.LEASE_TTL_MINUTES)
|
||||
)
|
||||
self.assertEqual(
|
||||
policy.stale_warning_minutes,
|
||||
float(reviewer_pr_lease.STALE_WARNING_MINUTES),
|
||||
)
|
||||
self.assertFalse(policy.heartbeat_lifecycle_active)
|
||||
|
||||
def test_declared_conflict_fix_value_matches_its_module(self):
|
||||
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_CONFLICT_FIX)
|
||||
self.assertEqual(
|
||||
policy.initial_ttl_minutes,
|
||||
float(pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES),
|
||||
)
|
||||
self.assertFalse(policy.heartbeat_lifecycle_active)
|
||||
|
||||
def test_environment_override_applies(self):
|
||||
var = lease_policy.env_var_name(
|
||||
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
|
||||
)
|
||||
with patch.dict(os.environ, {var: "7"}):
|
||||
self.assertEqual(
|
||||
lease_policy.policy_for(
|
||||
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
|
||||
).initial_ttl_minutes,
|
||||
7.0,
|
||||
)
|
||||
|
||||
def test_unusable_override_falls_back_instead_of_minting_a_zero_lease(self):
|
||||
"""A typo must not make every claim instantly reclaimable."""
|
||||
var = lease_policy.env_var_name(
|
||||
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
|
||||
)
|
||||
for bad in ("0", "-5", "not-a-number", " "):
|
||||
with self.subTest(value=bad), patch.dict(os.environ, {var: bad}):
|
||||
self.assertEqual(
|
||||
lease_policy.policy_for(
|
||||
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
|
||||
).initial_ttl_minutes,
|
||||
10.0,
|
||||
)
|
||||
|
||||
def test_unknown_task_class_does_not_raise(self):
|
||||
policy = lease_policy.policy_for("something-new")
|
||||
self.assertEqual(policy.task_class, lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
|
||||
|
||||
|
||||
class TestLifecycleDiscrimination(_LockFixture):
|
||||
"""AC-N8: the marker, never a timestamp, decides legacy vs heartbeat."""
|
||||
|
||||
def test_missing_marker_reads_as_legacy(self):
|
||||
record = self.write_lock(lifecycle=None)
|
||||
self.assertTrue(issue_lock_store.is_legacy_lease(record))
|
||||
self.assertEqual(
|
||||
issue_lock_store.lease_lifecycle_version(record),
|
||||
lease_policy.LIFECYCLE_LEGACY,
|
||||
)
|
||||
|
||||
def test_marker_present_reads_as_heartbeat_lifecycle(self):
|
||||
record = self.write_lock()
|
||||
self.assertFalse(issue_lock_store.is_legacy_lease(record))
|
||||
|
||||
def test_equal_created_and_heartbeat_never_implies_a_fresh_heartbeat(self):
|
||||
"""The exact inversion AC-N8 forbids.
|
||||
|
||||
A legacy lock has ``last_heartbeat_at == created_at`` forever because
|
||||
nothing ever advanced it. Reading that equality as "recently
|
||||
heartbeated" would classify every never-heartbeated lock as fresh.
|
||||
"""
|
||||
legacy = self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=3),
|
||||
heartbeat_delta=timedelta(hours=3),
|
||||
)
|
||||
lease = legacy["work_lease"]
|
||||
self.assertEqual(lease["created_at"], lease["last_heartbeat_at"])
|
||||
self.assertTrue(issue_lock_store.is_legacy_lease(legacy))
|
||||
|
||||
# A brand-new heartbeat lease has them equal too, so the equality
|
||||
# carries no information in either direction.
|
||||
fresh = self.write_lock(
|
||||
created_delta=timedelta(seconds=0), heartbeat_delta=timedelta(seconds=0)
|
||||
)
|
||||
self.assertEqual(
|
||||
fresh["work_lease"]["created_at"],
|
||||
fresh["work_lease"]["last_heartbeat_at"],
|
||||
)
|
||||
self.assertFalse(issue_lock_store.is_legacy_lease(fresh))
|
||||
|
||||
def test_minted_session_id_contains_no_pid(self):
|
||||
"""AC-N1: the ownership key must not be derived from the daemon pid."""
|
||||
minted = issue_lock_store.mint_task_session_id()
|
||||
self.assertNotIn(str(os.getpid()), minted)
|
||||
self.assertNotEqual(minted, issue_lock_store.mint_task_session_id())
|
||||
|
||||
|
||||
class TestFreshnessIsHeartbeatDriven(_LockFixture):
|
||||
"""AC-N2 and the new bands."""
|
||||
|
||||
def test_fresh_heartbeat_is_live(self):
|
||||
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
|
||||
self.assertTrue(fresh["live"])
|
||||
self.assertFalse(fresh["heartbeat_warning"])
|
||||
|
||||
def test_heartbeat_past_warning_is_still_live_but_flagged(self):
|
||||
record = self.write_lock(heartbeat_delta=timedelta(minutes=6))
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
|
||||
self.assertTrue(fresh["heartbeat_warning"])
|
||||
|
||||
def test_missed_heartbeat_past_grace_is_classified_explicitly(self):
|
||||
record = self.write_lock(
|
||||
heartbeat_delta=timedelta(minutes=11),
|
||||
expires_delta=timedelta(minutes=30),
|
||||
)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(
|
||||
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
|
||||
)
|
||||
self.assertFalse(fresh["live"])
|
||||
self.assertTrue(fresh["stale"])
|
||||
|
||||
def test_alive_pid_never_establishes_freshness(self):
|
||||
"""The defect in one assertion.
|
||||
|
||||
The recorded PID is this very process, so it is unambiguously alive —
|
||||
and the lease is still not live, because the task stopped heartbeating.
|
||||
"""
|
||||
record = self.write_lock(
|
||||
pid=os.getpid(),
|
||||
heartbeat_delta=timedelta(hours=4),
|
||||
expires_delta=timedelta(hours=4),
|
||||
)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertTrue(fresh["pid_alive"])
|
||||
self.assertFalse(fresh["live"])
|
||||
self.assertEqual(
|
||||
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
|
||||
)
|
||||
|
||||
def test_dead_pid_still_marks_stale_for_issue_753(self):
|
||||
"""The opposite asymmetry: dead-PID corroboration is preserved."""
|
||||
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE)
|
||||
self.assertFalse(fresh["live"])
|
||||
self.assertIn("not alive", fresh["reason"])
|
||||
|
||||
def test_absolute_cap_requires_readoption(self):
|
||||
record = self.write_lock(
|
||||
created_delta=timedelta(hours=9), heartbeat_delta=timedelta(minutes=1)
|
||||
)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE_ABSOLUTE_CAP)
|
||||
self.assertIn("re-adoption", fresh["reason"])
|
||||
|
||||
def test_heartbeat_lifecycle_without_a_heartbeat_fails_closed(self):
|
||||
record = self.write_lock()
|
||||
del record["work_lease"]["last_heartbeat_at"]
|
||||
issue_lock_store.save_lock_file(self._path(), record)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(
|
||||
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
|
||||
)
|
||||
self.assertIn("fail closed", fresh["reason"])
|
||||
|
||||
def test_absent_lock(self):
|
||||
fresh = issue_lock_store.assess_lock_freshness(None)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_ABSENT)
|
||||
self.assertFalse(fresh["stale"])
|
||||
|
||||
|
||||
class TestLegacyLocksStayProtected(_LockFixture):
|
||||
"""AC-N8: deployment must not retroactively shorten an existing claim."""
|
||||
|
||||
def test_legacy_lock_with_a_stale_heartbeat_remains_live(self):
|
||||
"""The deployment-safety case.
|
||||
|
||||
A four-hour legacy lease minted three hours ago has not heartbeated
|
||||
once. Under the new grace it would be long gone; under its preserved
|
||||
absolute expiry it is still live, and must stay that way.
|
||||
"""
|
||||
record = self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=3),
|
||||
heartbeat_delta=timedelta(hours=3),
|
||||
expires_delta=timedelta(hours=1),
|
||||
)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
|
||||
self.assertTrue(fresh["live"])
|
||||
self.assertTrue(fresh["legacy_lease"])
|
||||
self.assertTrue(fresh["legacy_expiry_preserved"])
|
||||
|
||||
def test_legacy_lock_past_its_absolute_expiry_is_expired_as_before(self):
|
||||
record = self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=5),
|
||||
heartbeat_delta=timedelta(hours=5),
|
||||
expires_delta=timedelta(hours=-1),
|
||||
)
|
||||
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
|
||||
self.assertEqual(fresh["status"], issue_lock_store.STATUS_EXPIRED)
|
||||
|
||||
def test_legacy_lock_is_never_reclaimed_by_the_heartbeat_band(self):
|
||||
record = self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=3),
|
||||
heartbeat_delta=timedelta(hours=3),
|
||||
expires_delta=timedelta(hours=1),
|
||||
)
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
|
||||
self.assertFalse(reclaim["reclaim_allowed"])
|
||||
|
||||
|
||||
class TestReclaimAfterMissedHeartbeat(_LockFixture):
|
||||
def test_missed_heartbeat_makes_ownership_reclaimable(self):
|
||||
record = self.write_lock(
|
||||
pid=os.getpid(),
|
||||
heartbeat_delta=timedelta(minutes=15),
|
||||
expires_delta=timedelta(hours=3),
|
||||
)
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
|
||||
self.assertTrue(reclaim["reclaim_allowed"])
|
||||
self.assertIn("stale_missed_heartbeat", reclaim["reasons"][0])
|
||||
|
||||
def test_live_lease_is_never_reclaimable(self):
|
||||
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
|
||||
self.assertFalse(reclaim["reclaim_allowed"])
|
||||
|
||||
def test_dead_pid_reclaim_path_is_unchanged(self):
|
||||
"""#753 must keep working through its original conditions."""
|
||||
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
|
||||
self.assertTrue(reclaim["reclaim_allowed"])
|
||||
self.assertTrue(reclaim["owner_pid_dead"])
|
||||
|
||||
|
||||
class TestHeartbeatWriter(_LockFixture):
|
||||
"""A4: flock + CAS + exact verification, and no revival path."""
|
||||
|
||||
def _heartbeat(self, **kwargs):
|
||||
params = {
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": self.worktree,
|
||||
"identity": IDENTITY,
|
||||
"profile": PROFILE,
|
||||
"task_session_id": "author_issue_work-aaaabbbbccccdddd",
|
||||
"lock_dir": self.lock_dir.name,
|
||||
"now": self.now,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return issue_lock_store.heartbeat_session_lock(**params)
|
||||
|
||||
def test_heartbeat_slides_expiry_and_advances_generation(self):
|
||||
self.write_lock(heartbeat_delta=timedelta(minutes=4), generation=5)
|
||||
result = self._heartbeat()
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(result["prior_generation"], 5)
|
||||
self.assertEqual(result["lock_generation"], 6)
|
||||
self.assertEqual(result["heartbeat_count"], 1)
|
||||
self.assertEqual(result["last_heartbeat_at"], _ts(self.now))
|
||||
self.assertEqual(result["expires_at"], _ts(self.now + timedelta(minutes=10)))
|
||||
self.assertTrue(result["freshness"]["live"])
|
||||
|
||||
def test_heartbeat_is_durable_and_repeatable(self):
|
||||
self.write_lock(heartbeat_delta=timedelta(minutes=4))
|
||||
self._heartbeat()
|
||||
second = self._heartbeat(now=self.now + timedelta(minutes=1))
|
||||
self.assertTrue(second["success"], second)
|
||||
self.assertEqual(second["heartbeat_count"], 2)
|
||||
written = issue_lock_store.read_lock_file(self._path())
|
||||
self.assertEqual(written["work_lease"]["heartbeat_count"], 2)
|
||||
|
||||
def test_stale_generation_is_refused(self):
|
||||
self.write_lock(generation=5)
|
||||
result = self._heartbeat(expected_generation=4)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("generation changed", result["reasons"][0])
|
||||
|
||||
def test_foreign_session_is_refused(self):
|
||||
self.write_lock()
|
||||
result = self._heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("task_session_id does not match", " ".join(result["reasons"]))
|
||||
|
||||
def test_missing_session_id_is_refused(self):
|
||||
self.write_lock()
|
||||
result = self._heartbeat(task_session_id="")
|
||||
self.assertFalse(result["success"])
|
||||
|
||||
def test_foreign_claimant_is_refused(self):
|
||||
self.write_lock()
|
||||
for field, value in (
|
||||
("identity", "someone-else"),
|
||||
("profile", "other-profile"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
result = self._heartbeat(**{field: value})
|
||||
self.assertFalse(result["success"])
|
||||
|
||||
def test_branch_and_worktree_mismatch_are_refused(self):
|
||||
self.write_lock()
|
||||
wrong_branch = self._heartbeat(branch_name=f"fix/issue-{ISSUE}-other")
|
||||
self.assertFalse(wrong_branch["success"])
|
||||
wrong_worktree = self._heartbeat(worktree_path="/tmp/not-the-worktree")
|
||||
self.assertFalse(wrong_worktree["success"])
|
||||
|
||||
def test_lapsed_lease_cannot_be_heartbeated_back_to_life(self):
|
||||
"""No revival path (A4).
|
||||
|
||||
A session that stopped proving liveness must reclaim under a fresh
|
||||
generation, not restore ownership retroactively.
|
||||
"""
|
||||
self.write_lock(
|
||||
heartbeat_delta=timedelta(minutes=30), expires_delta=timedelta(hours=1)
|
||||
)
|
||||
result = self._heartbeat()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("reclaimed", " ".join(result["reasons"]))
|
||||
|
||||
def test_absent_lock_cannot_be_created_by_heartbeat(self):
|
||||
result = self._heartbeat()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("no durable lock", result["reasons"][0])
|
||||
|
||||
def test_legacy_lock_is_refused_until_rebound(self):
|
||||
self.write_lock(lifecycle=None)
|
||||
result = self._heartbeat()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["legacy_lease"])
|
||||
self.assertIn("rebound", " ".join(result["reasons"]))
|
||||
|
||||
|
||||
class TestLegacyRebind(_LockFixture):
|
||||
"""AC-N8 exit route: canonical exact-owner rebinding."""
|
||||
|
||||
def _rebind(self, **kwargs):
|
||||
params = {
|
||||
"remote": REMOTE,
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": self.worktree,
|
||||
"identity": IDENTITY,
|
||||
"profile": PROFILE,
|
||||
"lock_dir": self.lock_dir.name,
|
||||
"now": self.now,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return issue_lock_store.rebind_legacy_lock(**params)
|
||||
|
||||
def test_rebind_mints_a_session_and_a_genuine_first_heartbeat(self):
|
||||
self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=3),
|
||||
heartbeat_delta=timedelta(hours=3),
|
||||
expires_delta=timedelta(hours=1),
|
||||
generation=2,
|
||||
)
|
||||
result = self._rebind()
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertTrue(result["task_session_id"])
|
||||
self.assertEqual(result["lock_generation"], 3)
|
||||
|
||||
written = issue_lock_store.read_lock_file(self._path())
|
||||
lease = written["work_lease"]
|
||||
self.assertEqual(
|
||||
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
|
||||
)
|
||||
self.assertEqual(lease["last_heartbeat_at"], _ts(self.now))
|
||||
self.assertEqual(lease["expires_at"], _ts(self.now + timedelta(minutes=10)))
|
||||
self.assertFalse(issue_lock_store.is_legacy_lease(written))
|
||||
# The original claim is preserved for audit rather than overwritten.
|
||||
origin = written["legacy_rebind"]["legacy_origin"]
|
||||
self.assertTrue(origin["created_at"])
|
||||
self.assertEqual(origin["lifecycle"], lease_policy.LIFECYCLE_LEGACY)
|
||||
|
||||
def test_rebound_lock_can_then_heartbeat(self):
|
||||
self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=3),
|
||||
heartbeat_delta=timedelta(hours=3),
|
||||
expires_delta=timedelta(hours=1),
|
||||
)
|
||||
rebound = self._rebind()
|
||||
beat = issue_lock_store.heartbeat_session_lock(
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.worktree,
|
||||
identity=IDENTITY,
|
||||
profile=PROFILE,
|
||||
task_session_id=rebound["task_session_id"],
|
||||
lock_dir=self.lock_dir.name,
|
||||
now=self.now + timedelta(minutes=1),
|
||||
)
|
||||
self.assertTrue(beat["success"], beat)
|
||||
|
||||
def test_rebind_refuses_a_foreign_owner(self):
|
||||
self.write_lock(lifecycle=None, expires_delta=timedelta(hours=1))
|
||||
result = self._rebind(identity="someone-else")
|
||||
self.assertFalse(result["success"])
|
||||
|
||||
def test_rebind_refuses_a_lock_already_on_the_lifecycle(self):
|
||||
self.write_lock()
|
||||
result = self._rebind()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["legacy_lease"])
|
||||
|
||||
def test_rebind_is_not_a_recovery_path_for_a_lapsed_legacy_lease(self):
|
||||
"""An expired legacy lease belongs to #760 renewal or #601 reclaim."""
|
||||
self.write_lock(
|
||||
lifecycle=None,
|
||||
created_delta=timedelta(hours=5),
|
||||
heartbeat_delta=timedelta(hours=5),
|
||||
expires_delta=timedelta(hours=-1),
|
||||
)
|
||||
result = self._rebind()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("not a recovery path", " ".join(result["reasons"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Conflict gate honors heartbeat-lifecycle non-live reclaim bands (#790 review #502/#516).
|
||||
|
||||
``assess_expired_lock_reclaim`` already permits reclaim for the heartbeat-lifecycle
|
||||
stale bands (``stale_missed_heartbeat``, ``stale_absolute_cap``) without a dead PID.
|
||||
But ``assess_same_issue_lease_conflict`` used to enter that reclaim branch only under
|
||||
``is_lease_expired`` (``expires_at <= now``). For a heartbeat-lifecycle lease that is
|
||||
non-live yet whose ``expires_at`` is still in the future, the acquisition gate fell
|
||||
through to the foreign "already has an active lease" block and never consulted the
|
||||
reclaim assessor — so the load-bearing heartbeat was not load-bearing for foreign
|
||||
reclaim, the exact abandonment scenario #790 exists to fix.
|
||||
|
||||
Two future-``expires_at`` non-live shapes are reachable:
|
||||
|
||||
* ``stale_absolute_cap`` — a session that keeps heartbeating past the 8h absolute cap
|
||||
has ``expires_at = last_heartbeat + TTL`` in the future (default policy).
|
||||
* ``stale_missed_heartbeat`` — under an independent TTL>grace policy the heartbeat
|
||||
grace lapses while ``expires_at`` is still ahead.
|
||||
|
||||
These tests pin: both reclaim from a foreign acquirer; a live lease still blocks a
|
||||
foreign acquirer; the same owner may reclaim its own abandoned heartbeat lease; and a
|
||||
legacy (pre-lifecycle) lock keeps its absolute-``expires_at`` clock — a non-expired
|
||||
legacy lock with a dead PID is *not* reclaimable through this path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import issue_lock_store as ils # noqa: E402
|
||||
import lease_policy # noqa: E402
|
||||
|
||||
ISSUE = 790
|
||||
OWNER_BRANCH = f"fix/issue-{ISSUE}-slice-a-heartbeat-policy"
|
||||
OWNER_WORKTREE = "/tmp/wt-790-owner"
|
||||
FOREIGN_BRANCH = f"fix/issue-{ISSUE}-foreign-attempt"
|
||||
FOREIGN_WORKTREE = "/tmp/wt-790-foreign"
|
||||
|
||||
|
||||
def _ts(moment: datetime) -> str:
|
||||
return (
|
||||
moment.astimezone(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _ConflictGateBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = datetime(2026, 7, 23, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def _lock(
|
||||
self,
|
||||
*,
|
||||
lifecycle: str | None,
|
||||
created_ago: timedelta,
|
||||
heartbeat_ago: timedelta,
|
||||
expires_in: timedelta,
|
||||
pid: int,
|
||||
) -> dict:
|
||||
lease: dict = {
|
||||
"operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"branch": OWNER_BRANCH,
|
||||
"worktree_path": OWNER_WORKTREE,
|
||||
"created_at": _ts(self.now - created_ago),
|
||||
"last_heartbeat_at": _ts(self.now - heartbeat_ago),
|
||||
"expires_at": _ts(self.now + expires_in),
|
||||
}
|
||||
if lifecycle is not None:
|
||||
lease["lifecycle_version"] = lifecycle
|
||||
lease["task_session_id"] = "author_issue_work-deadbeefdeadbeef"
|
||||
return {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": OWNER_BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": OWNER_WORKTREE,
|
||||
"session_pid": pid,
|
||||
"pid": pid,
|
||||
"work_lease": lease,
|
||||
}
|
||||
|
||||
def _foreign_conflict(self, existing: dict) -> str | None:
|
||||
return ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=ISSUE,
|
||||
branch_name=FOREIGN_BRANCH,
|
||||
worktree_path=FOREIGN_WORKTREE,
|
||||
now=self.now,
|
||||
)
|
||||
|
||||
def _same_owner_conflict(self, existing: dict) -> str | None:
|
||||
return ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=ISSUE,
|
||||
branch_name=OWNER_BRANCH,
|
||||
worktree_path=OWNER_WORKTREE,
|
||||
now=self.now,
|
||||
)
|
||||
|
||||
|
||||
class TestHeartbeatNonLiveFutureExpiresReclaim(_ConflictGateBase):
|
||||
def test_stale_absolute_cap_future_expires_allows_foreign_reclaim(self):
|
||||
# Created >8h ago, heartbeated one minute ago, expires 9 min in the FUTURE,
|
||||
# owner PID alive: freshness = stale_absolute_cap, live=False, not expired.
|
||||
existing = self._lock(
|
||||
lifecycle=lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||
created_ago=timedelta(hours=9),
|
||||
heartbeat_ago=timedelta(minutes=1),
|
||||
expires_in=timedelta(minutes=9),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
freshness = ils.assess_lock_freshness(existing, now=self.now)
|
||||
self.assertEqual(freshness["status"], ils.STATUS_STALE_ABSOLUTE_CAP)
|
||||
self.assertFalse(freshness["live"])
|
||||
self.assertFalse(ils.is_lease_expired(existing, now=self.now))
|
||||
with mock.patch.object(ils, "is_process_alive", return_value=True):
|
||||
self.assertIsNone(self._foreign_conflict(existing))
|
||||
|
||||
def test_missed_heartbeat_ttl_gt_grace_future_expires_allows_foreign_reclaim(self):
|
||||
# Heartbeat grace (default 10 min) lapsed 5 min ago, but a TTL>grace policy
|
||||
# leaves expires_at 20 min in the FUTURE: stale_missed_heartbeat, not expired.
|
||||
existing = self._lock(
|
||||
lifecycle=lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||
created_ago=timedelta(minutes=30),
|
||||
heartbeat_ago=timedelta(minutes=15),
|
||||
expires_in=timedelta(minutes=20),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
freshness = ils.assess_lock_freshness(existing, now=self.now)
|
||||
self.assertEqual(freshness["status"], ils.STATUS_STALE_MISSED_HEARTBEAT)
|
||||
self.assertFalse(freshness["live"])
|
||||
self.assertFalse(ils.is_lease_expired(existing, now=self.now))
|
||||
with mock.patch.object(ils, "is_process_alive", return_value=True):
|
||||
self.assertIsNone(self._foreign_conflict(existing))
|
||||
|
||||
def test_same_owner_may_reclaim_its_own_abandoned_heartbeat_lease(self):
|
||||
existing = self._lock(
|
||||
lifecycle=lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||
created_ago=timedelta(hours=9),
|
||||
heartbeat_ago=timedelta(minutes=1),
|
||||
expires_in=timedelta(minutes=9),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
with mock.patch.object(ils, "is_process_alive", return_value=True):
|
||||
self.assertIsNone(self._same_owner_conflict(existing))
|
||||
|
||||
|
||||
class TestLiveAndLegacyStillBlockForeign(_ConflictGateBase):
|
||||
def test_live_heartbeat_lease_still_blocks_foreign(self):
|
||||
existing = self._lock(
|
||||
lifecycle=lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||
created_ago=timedelta(minutes=5),
|
||||
heartbeat_ago=timedelta(minutes=1),
|
||||
expires_in=timedelta(minutes=9),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
freshness = ils.assess_lock_freshness(existing, now=self.now)
|
||||
self.assertTrue(freshness["live"])
|
||||
with mock.patch.object(ils, "is_process_alive", return_value=True):
|
||||
block = self._foreign_conflict(existing)
|
||||
self.assertIn("already has an active", block or "")
|
||||
|
||||
def test_legacy_lease_future_expires_dead_pid_is_not_reclaimed_here(self):
|
||||
# AC-N8: a legacy lock keeps its absolute expires_at clock. Non-expired +
|
||||
# dead PID is non-live, but the widened band excludes legacy, so the
|
||||
# foreign acquirer is still blocked rather than silently reclaiming.
|
||||
existing = self._lock(
|
||||
lifecycle=None,
|
||||
created_ago=timedelta(hours=9),
|
||||
heartbeat_ago=timedelta(hours=9),
|
||||
expires_in=timedelta(hours=2),
|
||||
pid=4_194_304, # far above any live pid on a test host
|
||||
)
|
||||
with mock.patch.object(ils, "is_process_alive", return_value=False):
|
||||
freshness = ils.assess_lock_freshness(existing, now=self.now)
|
||||
self.assertTrue(freshness["legacy_lease"])
|
||||
self.assertFalse(freshness["live"])
|
||||
self.assertFalse(ils.is_lease_expired(existing, now=self.now))
|
||||
block = self._foreign_conflict(existing)
|
||||
self.assertIn("already has an active", block or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Publication of an unpublished local commit (#812 AC20).
|
||||
|
||||
Entry point B of #812: a registered worktree, clean, on its issue branch,
|
||||
holding a local commit that has never been published. Exact-owner lease renewal
|
||||
refuses such a claim for want of an observable remote head, and every existing
|
||||
publication path is lock-derived, so the two predicates close a cycle around
|
||||
work that is otherwise complete.
|
||||
|
||||
These tests exercise the disposition through its *evidence*, never through any
|
||||
particular issue number: every case uses an arbitrary issue number against a
|
||||
synthetic repository, and the same assertions hold for any other. Nothing here
|
||||
reads, writes, or references the live protected worktree named in #812 AC17 —
|
||||
that content is preserved evidence for the duration of this work, so the
|
||||
fixtures below build their own repositories from scratch.
|
||||
|
||||
The remote is a local bare repository, so publication and read-after-write
|
||||
verification are genuinely executed rather than mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import branch_publish # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
ISSUE = 9812
|
||||
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
GIT_REMOTE = "prgs"
|
||||
|
||||
|
||||
def _ts(hours: int) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class _PublishBase(unittest.TestCase):
|
||||
"""Real git repo + real bare remote + durable lock naming the caller.
|
||||
|
||||
The recorded owner pid is deliberately **this live process**. That mirrors
|
||||
the production shape #812 documents, where the pid belongs to a long-running
|
||||
MCP daemon rather than to a dead author client, and it proves publication
|
||||
never depends on a dead process (#812 AC24).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
|
||||
self.repo = tempfile.mkdtemp(prefix="issue812-work-")
|
||||
for path in (self.origin, self.repo):
|
||||
self.addCleanup(
|
||||
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
|
||||
)
|
||||
self._init_repos()
|
||||
self.remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||
)
|
||||
self.remotes.start()
|
||||
self.addCleanup(patch.stopall)
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
# ── fixture construction ─────────────────────────────────────────────
|
||||
def _git(self, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
["git", "-C", cwd or self.repo, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _init_repos(self):
|
||||
subprocess.run(
|
||||
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
|
||||
)
|
||||
self._git("init", "-q", "-b", "master")
|
||||
self._git("config", "user.email", "[email protected]")
|
||||
self._git("config", "user.name", "Test")
|
||||
self._git("remote", "add", GIT_REMOTE, self.origin)
|
||||
|
||||
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
|
||||
fh.write("seed\n")
|
||||
self._git("add", "seed.txt")
|
||||
self._git("commit", "-q", "-m", "seed")
|
||||
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self._git("push", "-q", GIT_REMOTE, "master")
|
||||
|
||||
self._git("checkout", "-q", "-b", BRANCH)
|
||||
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
|
||||
fh.write("unpublished implementation\n")
|
||||
self._git("add", "work.txt")
|
||||
self._git("commit", "-q", "-m", "unpublished implementation")
|
||||
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self.worktree = os.path.realpath(self.repo)
|
||||
|
||||
def lock_path(self):
|
||||
return issue_lock_store.lock_file_path(
|
||||
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
|
||||
lock_dir=self.lock_dir.name,
|
||||
)
|
||||
|
||||
def write_lock(self, **overrides):
|
||||
path = self.lock_path()
|
||||
claimant = overrides.pop(
|
||||
"claimant", {"username": IDENTITY, "profile": PROFILE}
|
||||
)
|
||||
pid = overrides.pop("session_pid", os.getpid())
|
||||
lease = {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": overrides.get("branch_name", BRANCH),
|
||||
"worktree_path": overrides.get("worktree_path", self.worktree),
|
||||
"claimant": claimant,
|
||||
"created_at": _ts(-2),
|
||||
"last_heartbeat_at": _ts(-2),
|
||||
# Expired: entry point B's lease has lapsed, which is precisely why
|
||||
# renewal — and therefore a published head — is needed.
|
||||
"expires_at": _ts(-1),
|
||||
}
|
||||
lease.update(overrides.pop("work_lease", {}))
|
||||
data = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": self.worktree,
|
||||
"session_pid": pid,
|
||||
"pid": pid,
|
||||
"lock_generation": 1,
|
||||
"work_lease": lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue", claimant=claimant
|
||||
),
|
||||
}
|
||||
data.update(overrides)
|
||||
data["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, data)
|
||||
return path
|
||||
|
||||
def _tool_env(self):
|
||||
env = shared_mutation_env(
|
||||
PROFILE, include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||
)
|
||||
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
# These tests repoint PROJECT_ROOT at a synthetic repository so the
|
||||
# registered-worktree proof runs for real. Pin the parity gate to the
|
||||
# server's own startup head so that repointing does not read as a stale
|
||||
# daemon; the gate itself stays live and enforced.
|
||||
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
|
||||
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
|
||||
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
|
||||
return env
|
||||
|
||||
# ── tool driver ──────────────────────────────────────────────────────
|
||||
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
|
||||
"""Drive the public publication tool against the synthetic fixture."""
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||
), patch.object(
|
||||
mcp_server, "PROJECT_ROOT", self.repo
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_publish_unpublished_issue_branch(
|
||||
issue_number=kwargs.pop("issue_number", ISSUE),
|
||||
branch_name=kwargs.pop("branch_name", BRANCH),
|
||||
worktree_path=kwargs.pop("worktree_path", self.worktree),
|
||||
expected_head=expected_head or self.head_sha,
|
||||
remote="prgs",
|
||||
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def remote_head(self, branch=BRANCH):
|
||||
res = subprocess.run(
|
||||
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
class TestSuccessfulPublication(_PublishBase):
|
||||
"""AC20 — the branch becomes observable and is verified after the write."""
|
||||
|
||||
def test_publishes_clean_unpublished_commit(self):
|
||||
self.write_lock()
|
||||
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertTrue(result["published"])
|
||||
self.assertTrue(result["verified"], "read-after-write must be proven")
|
||||
self.assertEqual(result["remote_head_sha"], self.head_sha)
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
def test_publication_does_not_rewrite_the_commit(self):
|
||||
self.write_lock()
|
||||
self.run_publish()
|
||||
# The published object is the same commit, not a copy or a rewrite.
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
self.assertEqual(
|
||||
self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
|
||||
)
|
||||
|
||||
def test_exact_next_action_names_the_lock_call(self):
|
||||
self.write_lock()
|
||||
result = self.run_publish()
|
||||
self.assertIn("gitea_lock_issue", result["exact_next_action"])
|
||||
|
||||
|
||||
class TestFailsClosed(_PublishBase):
|
||||
"""AC20/AC9 — each refusal reason, exercised independently."""
|
||||
|
||||
def test_changed_local_head_refuses(self):
|
||||
self.write_lock()
|
||||
stale = self.base_sha # a real commit, but not the declared head
|
||||
result = self.run_publish(expected_head=stale)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("local commit changed" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertIsNone(self.remote_head(), "refusal must not publish")
|
||||
|
||||
def test_abbreviated_sha_refuses(self):
|
||||
self.write_lock()
|
||||
result = self.run_publish(expected_head=self.head_sha[:8])
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("40-character" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
|
||||
def test_dirty_tracked_worktree_refuses(self):
|
||||
self.write_lock()
|
||||
with open(os.path.join(self.repo, "work.txt"), "a") as fh:
|
||||
fh.write("uncommitted edit\n")
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("dirty tracked files" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_untracked_file_refuses(self):
|
||||
self.write_lock()
|
||||
with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
|
||||
fh.write("not committed\n")
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("untracked files" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
self.assertIn("stray.txt", result["evidence"]["untracked_files"])
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_unexpected_remote_head_refuses(self):
|
||||
"""A remote head that is not an ancestor must never be overwritten."""
|
||||
self.write_lock()
|
||||
# Publish a divergent commit to the branch from a separate line.
|
||||
self._git("checkout", "-q", "-b", "divergent", self.base_sha)
|
||||
with open(os.path.join(self.repo, "other.txt"), "w") as fh:
|
||||
fh.write("someone else's work\n")
|
||||
self._git("add", "other.txt")
|
||||
self._git("commit", "-q", "-m", "divergent")
|
||||
divergent = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
|
||||
self._git("checkout", "-q", BRANCH)
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.remote_head(), divergent, "the other head must survive intact"
|
||||
)
|
||||
|
||||
def test_fast_forward_remote_head_is_allowed(self):
|
||||
"""An ancestor head is an honest fast-forward, not a conflict."""
|
||||
self.write_lock()
|
||||
self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
self.assertTrue(result["evidence"]["fast_forward_from_remote"])
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
def test_content_hash_mismatch_refuses(self):
|
||||
self.write_lock()
|
||||
wrong = {"work.txt": "0" * 64}
|
||||
|
||||
result = self.run_publish(expected_file_hashes=wrong)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("declared content hashes" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertFalse(result["evidence"]["file_hashes_verified"])
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_matching_content_hashes_publish(self):
|
||||
self.write_lock()
|
||||
digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])
|
||||
|
||||
result = self.run_publish(expected_file_hashes=digests)
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
self.assertTrue(result["evidence"]["file_hashes_verified"])
|
||||
|
||||
def test_missing_declared_file_refuses(self):
|
||||
self.write_lock()
|
||||
result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("missing or unreadable" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_foreign_claimant_refuses(self):
|
||||
"""Ownership comes from the durable record, not from the caller."""
|
||||
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("foreign claim" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_foreign_profile_refuses(self):
|
||||
self.write_lock(
|
||||
claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
|
||||
)
|
||||
result = self.run_publish()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("claimant profile" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
|
||||
def test_absent_lock_record_refuses(self):
|
||||
"""No recorded claim means this cannot be used to bypass the lock."""
|
||||
result = self.run_publish() # no write_lock()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("no durable issue-lock record" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_branch_mismatch_against_lock_refuses(self):
|
||||
self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
|
||||
result = self.run_publish()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("records branch" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
|
||||
def test_worktree_mismatch_against_lock_refuses(self):
|
||||
self.write_lock(worktree_path="/tmp/some/other/worktree")
|
||||
result = self.run_publish()
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("records worktree" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
|
||||
def test_competing_open_pr_on_another_branch_refuses(self):
|
||||
self.write_lock()
|
||||
competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
|
||||
|
||||
result = self.run_publish(open_prs=competing)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("already claim issue" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_open_pr_on_the_same_branch_is_not_competing(self):
|
||||
"""This branch's own PR is not a rival claim against itself."""
|
||||
self.write_lock()
|
||||
own = [{"number": 77, "head": {"ref": BRANCH}}]
|
||||
|
||||
result = self.run_publish(open_prs=own)
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
|
||||
|
||||
class TestGuardStrictnessPreserved(_PublishBase):
|
||||
"""AC15 — publication is an operation, never a weakening of the guards."""
|
||||
|
||||
def test_non_issue_branch_refuses(self):
|
||||
self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
|
||||
self.write_lock(branch_name="scratch/not-issue-linked")
|
||||
|
||||
result = self.run_publish(branch_name="scratch/not-issue-linked")
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("issue-linked" in r for r in result["reasons"]), result["reasons"]
|
||||
)
|
||||
|
||||
def test_stable_branch_refuses(self):
|
||||
self.write_lock(branch_name="master")
|
||||
result = self.run_publish(branch_name="master")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_branch_number_must_match_the_issue(self):
|
||||
other = "feat/issue-7777-mismatched"
|
||||
self._git("checkout", "-q", "-b", other)
|
||||
self.write_lock(branch_name=other)
|
||||
result = self.run_publish(branch_name=other)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("does not carry issue number" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_unregistered_worktree_refuses(self):
|
||||
"""#713 — an improvised directory is not a registered worktree."""
|
||||
path = self.write_lock()
|
||||
assessment = branch_publish.assess_unpublished_commit_publication(
|
||||
issue_lock_store.read_lock_file(path),
|
||||
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
|
||||
expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
|
||||
identity=IDENTITY, profile=PROFILE,
|
||||
worktree_state={
|
||||
"current_branch": BRANCH, "porcelain_status": "",
|
||||
"head_sha": self.head_sha,
|
||||
},
|
||||
worktree_registered=False,
|
||||
remote_probe={"probe_ok": True, "remote_branch_exists": False},
|
||||
)
|
||||
self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
|
||||
self.assertTrue(
|
||||
any("not listed in git worktree list" in r
|
||||
for r in assessment["reasons"]),
|
||||
assessment["reasons"],
|
||||
)
|
||||
|
||||
def test_unobservable_remote_refuses(self):
|
||||
"""An unknown remote state must not be mistaken for an absent branch."""
|
||||
self.write_lock()
|
||||
result = self.run_publish(git_remote_name="no-such-remote")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(
|
||||
any("could not be observed" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestRecordSeparation(_PublishBase):
|
||||
"""AC23 — the durable issue lock and the workflow lease are distinct."""
|
||||
|
||||
def test_publication_leaves_the_issue_lock_byte_identical(self):
|
||||
path = self.write_lock()
|
||||
with open(path, "rb") as fh:
|
||||
before = fh.read()
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
with open(path, "rb") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(before, after, "publication must not mutate the lock record")
|
||||
self.assertFalse(result["issue_lock_record_mutated"])
|
||||
self.assertFalse(result["workflow_lease_touched"])
|
||||
|
||||
def test_refusal_also_reports_untouched_records(self):
|
||||
result = self.run_publish() # refuses: no lock record
|
||||
self.assertFalse(result["issue_lock_record_mutated"])
|
||||
self.assertFalse(result["workflow_lease_touched"])
|
||||
|
||||
def test_lock_generation_is_not_advanced(self):
|
||||
path = self.write_lock()
|
||||
self.run_publish()
|
||||
lock = issue_lock_store.read_lock_file(path)
|
||||
self.assertEqual(lock["lock_generation"], 1)
|
||||
|
||||
|
||||
class TestTruthfulProcessEvidence(_PublishBase):
|
||||
"""AC24 — a live daemon pid is never represented as a dead process."""
|
||||
|
||||
def test_live_recorded_pid_does_not_block_publication(self):
|
||||
# The recorded pid is this live process, standing in for the live MCP
|
||||
# daemon. Reclaim would refuse here; publication legitimately does not.
|
||||
path = self.write_lock(session_pid=os.getpid())
|
||||
lock = issue_lock_store.read_lock_file(path)
|
||||
self.assertEqual(lock["pid"], os.getpid())
|
||||
|
||||
result = self.run_publish()
|
||||
|
||||
self.assertTrue(result["success"], result.get("reasons"))
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
def test_liveness_is_not_consulted_as_evidence(self):
|
||||
self.write_lock(session_pid=os.getpid())
|
||||
result = self.run_publish()
|
||||
self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])
|
||||
|
||||
def test_reclaim_still_refuses_for_the_same_live_pid(self):
|
||||
"""Publication does not soften the reclaim predicate it routes around."""
|
||||
path = self.write_lock(session_pid=os.getpid())
|
||||
lock = issue_lock_store.read_lock_file(path)
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||
self.assertFalse(reclaim["reclaim_allowed"])
|
||||
|
||||
|
||||
class TestIdempotentRetry(_PublishBase):
|
||||
"""AC20 — retry is safe and read-after-write is proven every time."""
|
||||
|
||||
def test_second_publication_reports_already_published(self):
|
||||
self.write_lock()
|
||||
first = self.run_publish()
|
||||
self.assertTrue(first["performed"])
|
||||
|
||||
second = self.run_publish()
|
||||
|
||||
self.assertTrue(second["success"], second.get("reasons"))
|
||||
self.assertFalse(second["performed"], "no second push is needed")
|
||||
self.assertTrue(second["published"])
|
||||
self.assertTrue(second["verified"])
|
||||
self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
|
||||
class TestDryRun(_PublishBase):
|
||||
"""AC12 — dry run reports the decision and mutates nothing."""
|
||||
|
||||
def test_dry_run_reports_intent_without_publishing(self):
|
||||
self.write_lock()
|
||||
|
||||
result = self.run_publish(dry_run=True)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["dry_run"])
|
||||
self.assertTrue(result["would_publish"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertIsNone(self.remote_head(), "dry run must not publish")
|
||||
|
||||
def test_dry_run_and_apply_agree_on_a_refusal(self):
|
||||
"""AC11 — the reported decision does not depend on which mode ran."""
|
||||
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||
|
||||
dry = self.run_publish(dry_run=True)
|
||||
applied = self.run_publish()
|
||||
|
||||
self.assertFalse(dry["success"])
|
||||
self.assertFalse(applied["success"])
|
||||
self.assertEqual(dry["reasons"], applied["reasons"])
|
||||
|
||||
|
||||
class TestRenewalUnblocked(_PublishBase):
|
||||
"""AC20/AC21 — renewal is permitted only after verified publication."""
|
||||
|
||||
def _renewal(self, remote_head):
|
||||
return issue_lock_renewal.assess_exact_owner_lease_renewal(
|
||||
issue_lock_store.read_lock_file(self.lock_path()),
|
||||
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
|
||||
remote="prgs", org=ORG, repo=REPO,
|
||||
identity=IDENTITY, profile=PROFILE,
|
||||
current_branch=BRANCH, porcelain_status="", worktree_exists=True,
|
||||
head_sha=self.head_sha, remote_head_sha=remote_head,
|
||||
)
|
||||
|
||||
def test_renewal_refuses_before_publication(self):
|
||||
self.write_lock()
|
||||
decision = self._renewal(None)
|
||||
self.assertFalse(decision["renewal_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("unpublished branch" in r for r in decision["reasons"]),
|
||||
decision["reasons"],
|
||||
)
|
||||
|
||||
def test_renewal_is_sanctioned_after_publication(self):
|
||||
self.write_lock()
|
||||
result = self.run_publish()
|
||||
self.assertTrue(result["verified"], result.get("reasons"))
|
||||
|
||||
decision = self._renewal(self.remote_head())
|
||||
|
||||
self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])
|
||||
|
||||
|
||||
class TestProtectedAssetUntouched(unittest.TestCase):
|
||||
"""AC17 — no test or fixture may reference the protected worktree."""
|
||||
|
||||
def test_no_reference_to_the_protected_worktree(self):
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.dirname(here)
|
||||
needle = "issue-635-project-registry" + "-api"
|
||||
for path in (
|
||||
os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
|
||||
os.path.join(root, "branch_publish.py"),
|
||||
):
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
self.assertNotIn(needle, body)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -0,0 +1,622 @@
|
||||
"""Publication preflight must receive the caller's worktree (#815).
|
||||
|
||||
``gitea_publish_unpublished_issue_branch`` takes a **required** ``worktree_path``
|
||||
but resolved it only *after* ``verify_preflight_purity`` had already run. Every
|
||||
workspace-resolution layer behind that preflight — canonical root, root checkout,
|
||||
create-issue bootstrap, the #618 branches-only guard, issue scope, and anti-stomp
|
||||
— therefore received ``None`` and fell back to the MCP process root. A daemon
|
||||
rooted at the stable control checkout refused a valid registered issue worktree
|
||||
that the caller had explicitly supplied, before the publication assessor ever ran.
|
||||
|
||||
The #812 suite could not see this. Its fixture sets ``self.worktree =
|
||||
os.path.realpath(self.repo)`` and patches ``PROJECT_ROOT`` to that same path, so
|
||||
the fallback resolved to the very worktree the argument named. The production
|
||||
topology — control checkout on a stable branch, issue worktree somewhere else —
|
||||
was never constructed, and preflight additionally no-ops under pytest unless
|
||||
production guards are forced on.
|
||||
|
||||
These tests build that topology honestly:
|
||||
|
||||
* ``PROJECT_ROOT`` is a control checkout sitting on ``master``;
|
||||
* the registered issue worktree is a genuinely separate path under ``branches/``;
|
||||
* ``GITEA_TEST_FORCE_PRODUCTION_GUARDS`` is set so the #618 guard really runs;
|
||||
* no patch makes the issue worktree appear to be ``PROJECT_ROOT``.
|
||||
|
||||
Nothing here reads, writes, or references the protected worktree named in #812
|
||||
AC17 and #815 AC9. Every fixture is built from scratch against a local bare
|
||||
remote, so publication and read-after-write verification genuinely execute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
ISSUE = 9815
|
||||
BRANCH = f"feat/issue-{ISSUE}-forwarding-fixture"
|
||||
WORKTREE_DIRNAME = BRANCH.replace("/", "-")
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "test-author-prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
GIT_REMOTE = "prgs"
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
|
||||
def _ts(hours: int) -> str:
|
||||
return (
|
||||
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class TestPreflightReceivesTheWorktree(unittest.TestCase):
|
||||
"""AC1 — the supplied path reaches ``verify_preflight_purity`` itself.
|
||||
|
||||
Follows the #735 capture pattern: replace preflight with a recorder that
|
||||
raises, so the argument can be proven forwarded without performing the
|
||||
mutation. This is the direct unit-level statement of the defect.
|
||||
"""
|
||||
|
||||
def _capture_preflight(self, **kwargs):
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(*a, **kw):
|
||||
captured.update(kw)
|
||||
captured["_args"] = a
|
||||
raise RuntimeError("capture-only")
|
||||
|
||||
with patch.object(
|
||||
mcp_server, "verify_preflight_purity", side_effect=_capture
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=AUTHOR_PROFILE
|
||||
), patch.object(
|
||||
mcp_server, "_resolve",
|
||||
return_value=("gitea.prgs.cc", ORG, REPO),
|
||||
), patch.object(
|
||||
mcp_server, "_auth", return_value="token fake",
|
||||
), patch.object(
|
||||
mcp_server.role_session_router,
|
||||
"check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
), patch.object(
|
||||
mcp_server, "_namespace_mutation_block", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "_profile_permission_block", return_value=None
|
||||
):
|
||||
try:
|
||||
mcp_server.gitea_publish_unpublished_issue_branch(**kwargs)
|
||||
except RuntimeError as exc:
|
||||
if "capture-only" not in str(exc) and not captured:
|
||||
raise
|
||||
self.assertTrue(
|
||||
captured,
|
||||
"gitea_publish_unpublished_issue_branch never called "
|
||||
"verify_preflight_purity",
|
||||
)
|
||||
return captured
|
||||
|
||||
def _base_kwargs(self, **overrides):
|
||||
kwargs = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"worktree_path": "/tmp/issue-815-explicit-worktree",
|
||||
"expected_head": "a" * 40,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"git_remote_name": GIT_REMOTE,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return kwargs
|
||||
|
||||
def test_explicit_worktree_path_reaches_preflight(self):
|
||||
captured = self._capture_preflight(**self._base_kwargs())
|
||||
self.assertEqual(
|
||||
captured.get("worktree_path"),
|
||||
os.path.realpath(os.path.abspath("/tmp/issue-815-explicit-worktree")),
|
||||
"the authoritative worktree_path must be forwarded into preflight",
|
||||
)
|
||||
|
||||
def test_forwarded_path_is_the_one_publication_uses(self):
|
||||
"""AC4 — preflight and publication must judge the same resolved path."""
|
||||
raw = "/tmp/issue-815-explicit-worktree/./"
|
||||
captured = self._capture_preflight(**self._base_kwargs(worktree_path=raw))
|
||||
expected = os.path.realpath(os.path.abspath(raw.strip()))
|
||||
self.assertEqual(captured.get("worktree_path"), expected)
|
||||
|
||||
def test_blank_worktree_path_forwards_none(self):
|
||||
"""AC5/AC8 — nothing usable supplied keeps the fail-closed fallback."""
|
||||
for blank in ("", " "):
|
||||
with self.subTest(blank=repr(blank)):
|
||||
captured = self._capture_preflight(
|
||||
**self._base_kwargs(worktree_path=blank)
|
||||
)
|
||||
self.assertIsNone(
|
||||
captured.get("worktree_path"),
|
||||
"a blank worktree must not resolve to the process cwd",
|
||||
)
|
||||
|
||||
def test_org_repo_and_task_forwarding_are_not_regressed(self):
|
||||
"""AC6 — #735's org/repo forwarding and the task name still hold."""
|
||||
captured = self._capture_preflight(**self._base_kwargs())
|
||||
self.assertEqual(captured.get("org"), ORG)
|
||||
self.assertEqual(captured.get("repo"), REPO)
|
||||
self.assertEqual(captured.get("task"), "publish_unpublished_branch")
|
||||
|
||||
|
||||
class _ProductionTopologyBase(unittest.TestCase):
|
||||
"""Control checkout on master + a distinct registered issue worktree.
|
||||
|
||||
This is the shape the production daemon runs in and the shape the #812
|
||||
fixture never built. ``PROJECT_ROOT`` is the control checkout; the issue
|
||||
worktree is a real registered worktree at a different path; production
|
||||
guards are forced on so the #618 branches-only guard genuinely evaluates.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.lock_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.lock_dir.cleanup)
|
||||
self.origin = tempfile.mkdtemp(prefix="issue815-origin-")
|
||||
self.control = tempfile.mkdtemp(prefix="issue815-control-")
|
||||
for path in (self.origin, self.control):
|
||||
self.addCleanup(
|
||||
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
|
||||
)
|
||||
self._init_repos()
|
||||
self.remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||
)
|
||||
self.remotes.start()
|
||||
self.addCleanup(patch.stopall)
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def _git(self, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
["git", "-C", cwd or self.control, *args],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
|
||||
def _init_repos(self):
|
||||
subprocess.run(
|
||||
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
|
||||
)
|
||||
self._git("init", "-q", "-b", "master")
|
||||
self._git("config", "user.email", "[email protected]")
|
||||
self._git("config", "user.name", "Test")
|
||||
self._git("remote", "add", GIT_REMOTE, self.origin)
|
||||
|
||||
with open(os.path.join(self.control, "seed.txt"), "w") as fh:
|
||||
fh.write("seed\n")
|
||||
# The real repository gitignores branches/, so a registered worktree
|
||||
# living there does not dirty the stable control checkout. Mirror that,
|
||||
# or the #615 dirty-runtime block fires on the worktree we just created.
|
||||
with open(os.path.join(self.control, ".gitignore"), "w") as fh:
|
||||
fh.write("branches/\n")
|
||||
self._git("add", "seed.txt", ".gitignore")
|
||||
self._git("commit", "-q", "-m", "seed")
|
||||
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||
self._git("push", "-q", GIT_REMOTE, "master")
|
||||
|
||||
# The control checkout STAYS on master. This is the whole point: the
|
||||
# daemon's process root is the stable control checkout, never the
|
||||
# worktree the publication targets.
|
||||
self.worktree = os.path.realpath(
|
||||
os.path.join(self.control, "branches", WORKTREE_DIRNAME)
|
||||
)
|
||||
self._git("worktree", "add", "-q", "-b", BRANCH, self.worktree, "master")
|
||||
|
||||
with open(os.path.join(self.worktree, "work.txt"), "w") as fh:
|
||||
fh.write("unpublished implementation\n")
|
||||
self._git("add", "work.txt", cwd=self.worktree)
|
||||
self._git("commit", "-q", "-m", "unpublished implementation", cwd=self.worktree)
|
||||
self.head_sha = self._git("rev-parse", "HEAD", cwd=self.worktree).stdout.strip()
|
||||
|
||||
self.control_branch = self._git(
|
||||
"rev-parse", "--abbrev-ref", "HEAD"
|
||||
).stdout.strip()
|
||||
|
||||
# ── durable lock naming the caller and the issue worktree ────────────
|
||||
def lock_path(self):
|
||||
return issue_lock_store.lock_file_path(
|
||||
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
|
||||
lock_dir=self.lock_dir.name,
|
||||
)
|
||||
|
||||
def write_lock(self, *, bind_session=True, **overrides):
|
||||
path = self.lock_path()
|
||||
claimant = overrides.pop(
|
||||
"claimant", {"username": IDENTITY, "profile": PROFILE}
|
||||
)
|
||||
pid = overrides.pop("session_pid", os.getpid())
|
||||
lease = {
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": ISSUE,
|
||||
"pr_number": None,
|
||||
"branch": overrides.get("branch_name", BRANCH),
|
||||
"worktree_path": overrides.get("worktree_path", self.worktree),
|
||||
"claimant": claimant,
|
||||
"created_at": _ts(-2),
|
||||
"last_heartbeat_at": _ts(-2),
|
||||
"expires_at": _ts(-1),
|
||||
}
|
||||
lease.update(overrides.pop("work_lease", {}))
|
||||
data = {
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
"worktree_path": self.worktree,
|
||||
"session_pid": pid,
|
||||
"pid": pid,
|
||||
"lock_generation": 1,
|
||||
"work_lease": lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue", claimant=claimant
|
||||
),
|
||||
}
|
||||
data.update(overrides)
|
||||
data["lock_file_path"] = path
|
||||
issue_lock_store.save_lock_file(path, data)
|
||||
# Bind the session pointer so the #683 issue-scope guard resolves an
|
||||
# owning issue for this author session. In real production the publish
|
||||
# task does not require a session lock — require_author_lock is keyed on
|
||||
# the test-only production_guards_forced() flag, which this suite must
|
||||
# set to make preflight run at all — so this pointer is fixture
|
||||
# scaffolding to clear a guard production would not apply here, never a
|
||||
# softening of the worktree-forwarding behaviour under test. The
|
||||
# preflight-negative cases below leave it unbound precisely so the #618
|
||||
# guard is reached with no session fallback to rescue a bad worktree.
|
||||
if bind_session:
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": ISSUE,
|
||||
"branch_name": data["branch_name"],
|
||||
"remote": "prgs",
|
||||
"org": ORG,
|
||||
"repo": REPO,
|
||||
}
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.session_pointer_path(self.lock_dir.name), pointer
|
||||
)
|
||||
return path
|
||||
|
||||
def _tool_env(self):
|
||||
env = shared_mutation_env(
|
||||
PROFILE, include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||
)
|
||||
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
# The defect only exists where preflight actually runs. Under pytest the
|
||||
# production root/branches/scope guards are skipped unless forced on, so
|
||||
# force them: this test exists to exercise the #618 guard, not to bypass
|
||||
# it. Parity is pinned to the server's own startup head so repointing
|
||||
# PROJECT_ROOT does not read as a stale daemon.
|
||||
env["GITEA_TEST_FORCE_PRODUCTION_GUARDS"] = "1"
|
||||
# Production is a promoted stable-control runtime. The pytest process
|
||||
# itself runs from a branches/ worktree, which the #615 runtime-mode
|
||||
# gate correctly classifies as dev-test; declaring the sanctioned mode
|
||||
# models the production daemon rather than defeating the gate. Without
|
||||
# this, forcing production guards on would trip the *runtime-mode* block
|
||||
# for a reason unrelated to the #815 worktree-forwarding defect.
|
||||
env["GITEA_MCP_RUNTIME_MODE"] = "stable-control"
|
||||
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
|
||||
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
|
||||
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
|
||||
return env
|
||||
|
||||
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
|
||||
"""Drive the public tool with PROJECT_ROOT pinned to the CONTROL checkout."""
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||
), patch.object(
|
||||
# NOTE: the control checkout — deliberately NOT self.worktree.
|
||||
mcp_server, "PROJECT_ROOT", self.control
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
return mcp_server.gitea_publish_unpublished_issue_branch(
|
||||
issue_number=kwargs.pop("issue_number", ISSUE),
|
||||
branch_name=kwargs.pop("branch_name", BRANCH),
|
||||
worktree_path=kwargs.pop("worktree_path", self.worktree),
|
||||
expected_head=expected_head or self.head_sha,
|
||||
remote="prgs",
|
||||
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def remote_head(self, branch=BRANCH):
|
||||
res = subprocess.run(
|
||||
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
class TestForwardingClearsThe618Guard(_ProductionTopologyBase):
|
||||
"""AC2 — the faithful production reproduction, and the sharpest fix proof.
|
||||
|
||||
The production recovery worker had **no** session issue lock — acquiring one
|
||||
was the very thing the deadlock prevented — so preflight had nothing but the
|
||||
explicit ``worktree_path`` argument to resolve the workspace from. This class
|
||||
reproduces exactly that: no session pointer is bound, so there is no
|
||||
author-lock fallback to rescue a dropped argument.
|
||||
|
||||
With the argument forwarded (fixed source) the #618 branches-only guard
|
||||
accepts the registered issue worktree and the call advances to the next
|
||||
guard. With the argument dropped (the buggy source this issue reports)
|
||||
preflight falls back to ``PROJECT_ROOT`` — the stable control checkout — and
|
||||
the #618 guard traps the call there. The two outcomes are told apart by the
|
||||
guard that fired, on its own error text.
|
||||
|
||||
This test therefore *fails* against the unpatched source (the call is trapped
|
||||
at #618 instead of clearing it), which is what makes it a regression rather
|
||||
than a smoke test.
|
||||
"""
|
||||
|
||||
_CONTROL_CHECKOUT_MARKERS = ("stable control checkout", "#618")
|
||||
|
||||
def test_explicit_worktree_clears_618_without_a_session_lock(self):
|
||||
# No write_lock(): the session is deliberately unbound, as in production.
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.run_publish()
|
||||
message = str(ctx.exception)
|
||||
# The workspace guard is satisfied — the failure is the *later* scope
|
||||
# guard (no owning issue), never the control-checkout refusal. If the
|
||||
# argument were dropped, this call would be trapped at #618 instead.
|
||||
for marker in self._CONTROL_CHECKOUT_MARKERS:
|
||||
self.assertNotIn(
|
||||
marker, message,
|
||||
f"the explicit worktree must clear #618; got a control-checkout "
|
||||
f"refusal instead: {message}",
|
||||
)
|
||||
self.assertIn(
|
||||
"owning issue", message,
|
||||
f"expected the downstream scope guard to fire, got: {message}",
|
||||
)
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
def test_dropped_argument_would_be_trapped_at_618(self):
|
||||
# Simulate the buggy call shape directly: no session lock, and preflight
|
||||
# given no worktree, exactly as the unpatched source left it. This pins
|
||||
# the control-checkout refusal that the fix eliminates, so the pair of
|
||||
# tests brackets the defect from both sides regardless of which source
|
||||
# version is loaded.
|
||||
env = self._tool_env()
|
||||
with patch(
|
||||
"mcp_server._list_open_pulls", return_value=[]
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="token x"
|
||||
), patch(
|
||||
"mcp_server._work_lease_claimant",
|
||||
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||
), patch.object(
|
||||
mcp_server, "PROJECT_ROOT", self.control
|
||||
), patch.dict(os.environ, env, clear=True):
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
# Drive verify_preflight_purity the way the buggy body did:
|
||||
# no worktree_path forwarded at all.
|
||||
mcp_server.verify_preflight_purity(
|
||||
"prgs",
|
||||
task="publish_unpublished_branch",
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
)
|
||||
message = str(ctx.exception)
|
||||
self.assertTrue(
|
||||
any(m in message for m in self._CONTROL_CHECKOUT_MARKERS),
|
||||
f"a dropped worktree must trap at the control checkout: {message}",
|
||||
)
|
||||
self.assertIsNone(self.remote_head())
|
||||
|
||||
|
||||
class TestProductionTopologyPublishes(_ProductionTopologyBase):
|
||||
"""AC2/AC4/AC7 — the explicit registered worktree is what preflight validates."""
|
||||
|
||||
def test_fixture_is_genuinely_the_production_topology(self):
|
||||
"""Guard the guard: if this drifts, the regression stops meaning anything."""
|
||||
self.assertNotEqual(
|
||||
os.path.realpath(self.control), self.worktree,
|
||||
"the issue worktree must not be PROJECT_ROOT",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.control_branch, "master",
|
||||
"the control checkout must sit on a stable branch",
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.realpath(self.worktree).startswith(
|
||||
os.path.realpath(os.path.join(self.control, "branches")) + os.sep
|
||||
),
|
||||
"the issue worktree must live under branches/",
|
||||
)
|
||||
listed = subprocess.run(
|
||||
["git", "-C", self.control, "worktree", "list"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
self.assertIn(
|
||||
self.worktree, listed, "the issue worktree must be genuinely registered"
|
||||
)
|
||||
|
||||
def test_publishes_from_a_control_rooted_daemon(self):
|
||||
"""The exact production failure: this refused with #618 before the fix."""
|
||||
self.write_lock()
|
||||
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
|
||||
res = self.run_publish()
|
||||
self.assertTrue(res.get("success"), res)
|
||||
self.assertTrue(res.get("performed"), res)
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
def test_dry_run_uses_the_explicit_worktree(self):
|
||||
"""AC4 — dry-run reaches the same decision without publishing."""
|
||||
self.write_lock()
|
||||
res = self.run_publish(dry_run=True)
|
||||
self.assertTrue(res.get("success"), res)
|
||||
self.assertFalse(res.get("performed"), res)
|
||||
self.assertTrue(res.get("would_publish"), res)
|
||||
self.assertIsNone(self.remote_head(), "dry-run must not publish")
|
||||
|
||||
def test_dry_run_and_apply_agree_on_the_same_worktree(self):
|
||||
"""AC4 — both paths resolve the same workspace, so both succeed."""
|
||||
self.write_lock()
|
||||
dry = self.run_publish(dry_run=True)
|
||||
self.assertTrue(dry.get("would_publish"), dry)
|
||||
applied = self.run_publish()
|
||||
self.assertTrue(applied.get("performed"), applied)
|
||||
self.assertEqual(self.remote_head(), self.head_sha)
|
||||
|
||||
def test_read_after_write_verification_still_runs(self):
|
||||
"""AC6 — PR #814's post-publication verification is unchanged."""
|
||||
self.write_lock()
|
||||
res = self.run_publish()
|
||||
self.assertTrue(res.get("verified"), res)
|
||||
self.assertEqual(res.get("remote_head_sha"), self.head_sha)
|
||||
|
||||
|
||||
class TestProductionTopologyFailsClosed(_ProductionTopologyBase):
|
||||
"""AC3/AC5/AC8 — the fix does not weaken any refusal.
|
||||
|
||||
A refusal reaches the caller by one of two mechanisms, and this class holds
|
||||
them apart deliberately. A bad *workspace* is caught by the #618 preflight
|
||||
guard, which raises before the assessor is built. A bad *content/ownership*
|
||||
fact passes preflight (the worktree itself is fine) and is then refused by
|
||||
the publication assessor, which returns ``success: False``. Both are
|
||||
fail-closed; asserting the wrong mechanism would hide a regression.
|
||||
"""
|
||||
|
||||
# ── #618 preflight refusals: no session lock, so nothing rescues a bad
|
||||
# workspace and the guard fires exactly as it does in production ──────
|
||||
def _assert_preflight_raises(self, **kwargs):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.run_publish(**kwargs)
|
||||
self.assertIsNone(
|
||||
self.remote_head(), "a blocked publication must not reach the remote"
|
||||
)
|
||||
return str(ctx.exception)
|
||||
|
||||
def test_blank_worktree_path_fails_closed_via_618(self):
|
||||
"""AC5 — a blank path forwards None, so preflight sees the control root."""
|
||||
for blank in ("", " "):
|
||||
with self.subTest(blank=repr(blank)):
|
||||
message = self._assert_preflight_raises(worktree_path=blank)
|
||||
self.assertIn("618", message)
|
||||
|
||||
def test_control_checkout_as_worktree_fails_closed_via_618(self):
|
||||
"""AC5 — naming the stable control checkout explicitly is still refused."""
|
||||
message = self._assert_preflight_raises(worktree_path=self.control)
|
||||
self.assertIn("618", message)
|
||||
|
||||
def test_unregistered_directory_fails_closed(self):
|
||||
"""AC3 — a plain directory under branches/ is not a registered worktree."""
|
||||
bogus = os.path.join(self.control, "branches", "not-a-worktree")
|
||||
os.makedirs(bogus, exist_ok=True)
|
||||
self._assert_preflight_raises(worktree_path=bogus)
|
||||
|
||||
def test_missing_worktree_path_fails_closed(self):
|
||||
"""AC3 — a path that does not exist is refused, not silently replaced."""
|
||||
missing = os.path.join(self.control, "branches", "absent-worktree")
|
||||
self._assert_preflight_raises(worktree_path=missing)
|
||||
|
||||
# ── assessor refusals: preflight passes on a valid worktree, then the
|
||||
# publication assessor refuses on content/ownership evidence ──────────
|
||||
def _assert_assessor_refuses(self, **kwargs):
|
||||
res = self.run_publish(**kwargs)
|
||||
self.assertFalse(res.get("success"), res)
|
||||
self.assertFalse(res.get("performed"), res)
|
||||
self.assertIsNone(self.remote_head())
|
||||
return res
|
||||
|
||||
def test_changed_local_head_still_refuses(self):
|
||||
"""AC6 — the declared expected_head remains authoritative."""
|
||||
self.write_lock()
|
||||
self._assert_assessor_refuses(expected_head="b" * 40)
|
||||
|
||||
def test_foreign_claimant_still_refuses(self):
|
||||
"""AC6 — ownership still comes from the durable lock record."""
|
||||
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||
self._assert_assessor_refuses()
|
||||
|
||||
def test_dirty_worktree_still_refuses(self):
|
||||
"""AC6 — cleanliness enforcement survives the forwarding change."""
|
||||
self.write_lock()
|
||||
with open(os.path.join(self.worktree, "work.txt"), "a") as fh:
|
||||
fh.write("uncommitted drift\n")
|
||||
self._assert_assessor_refuses()
|
||||
|
||||
def test_competing_open_pr_still_refuses(self):
|
||||
"""AC6 — a rival claim on another branch still blocks."""
|
||||
self.write_lock()
|
||||
self._assert_assessor_refuses(
|
||||
open_prs=[{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
|
||||
)
|
||||
|
||||
def test_issue_lock_record_is_not_mutated_by_a_refusal(self):
|
||||
"""AC6 — record separation (#812 AC23) is unaffected by this change."""
|
||||
path = self.write_lock()
|
||||
with open(path, "rb") as fh:
|
||||
before = fh.read()
|
||||
self._assert_assessor_refuses(expected_head="c" * 40)
|
||||
with open(path, "rb") as fh:
|
||||
self.assertEqual(before, fh.read())
|
||||
|
||||
|
||||
class TestProtectedFixtureNotReferenced(unittest.TestCase):
|
||||
"""AC9 — this regression never names the protected #635 fixture.
|
||||
|
||||
The forbidden tokens are reconstructed from fragments so this assertion
|
||||
file does not itself contain them and produce a false positive.
|
||||
"""
|
||||
|
||||
def test_no_reference_to_the_protected_worktree(self):
|
||||
forbidden = [
|
||||
"issue-635-" + "project-registry-api",
|
||||
"b2f6e9a6dc40e9651ef8" + "76f322dd0a68bddebfd8",
|
||||
]
|
||||
here = os.path.abspath(__file__)
|
||||
with open(here, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
for token in forbidden:
|
||||
self.assertNotIn(
|
||||
token, text,
|
||||
f"the protected #635 fixture must not be referenced: {token}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Cross-role allocation handoff consumable by independent workers (#843).
|
||||
|
||||
Regression coverage for the controller→required-role consume path:
|
||||
|
||||
* controller allocates author work; independent author adopts successfully
|
||||
* author adoption succeeds after allocating controller process exits
|
||||
* author adoption without sharing controller session identity
|
||||
* wrong-role adoption rejected
|
||||
* concurrent/second adoption rejected without state corruption
|
||||
* terminal allocation adoption rejected
|
||||
* successful adoption produces authoritative ownership evidence
|
||||
* genuine abandoned-lease recovery remains valid
|
||||
* process_work_queue / allocate results include consume identifiers
|
||||
* same-role allocation behavior remains compatible
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from allocator_service import (
|
||||
ALLOCATION_MODE_CROSS_ROLE,
|
||||
ALLOCATION_MODE_ROLE_SCOPED,
|
||||
OUTCOME_ASSIGNED,
|
||||
ROLE_AUTHOR,
|
||||
ROLE_CONTROLLER,
|
||||
ROLE_REVIEWER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB, ForeignLeaseError, _ts, _utc_now
|
||||
import lease_lifecycle as ll
|
||||
|
||||
|
||||
class CrossRoleHandoffTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
self.db.upsert_session(
|
||||
session_id="ctrl-session",
|
||||
role="controller",
|
||||
profile="prgs-controller",
|
||||
pid=99999999, # dead-looking pid
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker-2",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="reviewer-worker",
|
||||
role="reviewer",
|
||||
profile="prgs-reviewer",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.wt = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _ready_issue(self, number: int = 843, title: str = "handoff target") -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=title,
|
||||
priority=20,
|
||||
)
|
||||
|
||||
def _controller_allocate(self, number: int = 843, **kwargs):
|
||||
defaults = dict(
|
||||
db=self.db,
|
||||
session_id="ctrl-session",
|
||||
role=ROLE_CONTROLLER,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(number)],
|
||||
apply=True,
|
||||
profile_name="prgs-controller",
|
||||
username="controller-user",
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(**defaults)
|
||||
|
||||
def test_controller_allocates_author_independent_author_adopts(self) -> None:
|
||||
res = self._controller_allocate()
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
self.assertIn("consume_allocation", res)
|
||||
consume = res["consume_allocation"]
|
||||
self.assertEqual(consume["tool"], "gitea_adopt_workflow_lease")
|
||||
self.assertEqual(consume["required_role"], ROLE_AUTHOR)
|
||||
self.assertFalse(consume["controller_session_required"])
|
||||
lid = res["assignment"]["lease_id"]
|
||||
self.assertEqual(consume["lease_id"], lid)
|
||||
self.assertIn(lid, res["next_valid_command"])
|
||||
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(adopted["success"])
|
||||
self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")
|
||||
raw = adopted["read_after_write"]
|
||||
self.assertEqual(raw["session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(raw["status"], "active")
|
||||
self.assertEqual(raw["phase"], "adopted")
|
||||
|
||||
# Authoritative re-read
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "adopted")
|
||||
|
||||
def test_author_adoption_after_controller_process_exits(self) -> None:
|
||||
res = self._controller_allocate(number=900)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# Force owner_pid dead + freshness stale_dead_process
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999 WHERE lease_id = ?",
|
||||
(lid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
fr = ll.classify_lease_freshness(
|
||||
state["lease"], pid_checker=lambda _p: False
|
||||
)
|
||||
self.assertEqual(fr["freshness"], "stale_dead_process")
|
||||
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
|
||||
# No abandon required
|
||||
state2 = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state2["lease"]["status"], "active")
|
||||
self.assertNotEqual(state2["lease"]["status"], "abandoned")
|
||||
|
||||
def test_adoption_without_sharing_controller_session_identity(self) -> None:
|
||||
res = self._controller_allocate(number=901)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertNotEqual(adopted["adopted_by_session_id"], "ctrl-session")
|
||||
self.assertFalse(adopted["same_owner"])
|
||||
self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")
|
||||
|
||||
def test_wrong_role_adoption_rejected(self) -> None:
|
||||
res = self._controller_allocate(number=902)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
with self.assertRaises(ll.LeaseLifecycleError) as ctx:
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="reviewer-worker",
|
||||
role=ROLE_REVIEWER,
|
||||
)
|
||||
self.assertIn("wrong role", str(ctx.exception).lower())
|
||||
# State unchanged
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "ctrl-session")
|
||||
self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "pending")
|
||||
|
||||
def test_second_adoption_rejected_without_corruption(self) -> None:
|
||||
res = self._controller_allocate(number=903)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
first = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
|
||||
with self.assertRaises(ll.LeaseLifecycleError):
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["status"], "active")
|
||||
|
||||
def test_terminal_allocation_adoption_rejected(self) -> None:
|
||||
res = self._controller_allocate(number=904)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# Abandon as terminal
|
||||
proof = ll.AbandonProof(
|
||||
dead_process=True,
|
||||
missing_worktree=True,
|
||||
no_open_pr=True,
|
||||
no_live_mutation_risk=True,
|
||||
owner_pid=99999999,
|
||||
worktree_path="/nonexistent/for-843",
|
||||
)
|
||||
# Attach dead pid / missing wt for abandon eligibility
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
|
||||
("/nonexistent/for-843", lid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
abandoned = ll.abandon_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
requester_session_id="author-worker",
|
||||
proof=proof,
|
||||
)
|
||||
self.assertEqual(abandoned["outcome"], "abandoned")
|
||||
with self.assertRaises(ll.LeaseLifecycleError) as ctx:
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
)
|
||||
self.assertIn("abandoned", str(ctx.exception).lower())
|
||||
|
||||
def test_successful_adoption_read_after_write_ownership(self) -> None:
|
||||
res = self._controller_allocate(number=905)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
raw = adopted["read_after_write"]
|
||||
self.assertEqual(raw["lease_id"], lid)
|
||||
self.assertEqual(raw["session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_from_session_id"], "ctrl-session")
|
||||
# Re-fetch proves durable write
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], raw["session_id"])
|
||||
self.assertEqual(
|
||||
state["lease"]["adopted_by_session_id"], raw["adopted_by_session_id"]
|
||||
)
|
||||
|
||||
def test_genuine_abandoned_recovery_still_valid(self) -> None:
|
||||
"""Same-role author lease abandoned remains reclaimable via abandon path."""
|
||||
same = allocate_next_work(
|
||||
self.db,
|
||||
session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(906, "same-role")],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
username="author-user",
|
||||
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
self.assertEqual(same["outcome"], OUTCOME_ASSIGNED)
|
||||
lid = same["assignment"]["lease_id"]
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
|
||||
("/nonexistent/same-role", lid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
proof = ll.AbandonProof(
|
||||
dead_process=True,
|
||||
missing_worktree=True,
|
||||
no_open_pr=True,
|
||||
no_live_mutation_risk=True,
|
||||
owner_pid=99999999,
|
||||
worktree_path="/nonexistent/same-role",
|
||||
)
|
||||
abandoned = ll.abandon_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
requester_session_id="author-worker-2",
|
||||
proof=proof,
|
||||
)
|
||||
self.assertEqual(abandoned["outcome"], "abandoned")
|
||||
# Foreign author cannot handoff-consume an abandoned non-handoff lease
|
||||
with self.assertRaises(ll.LeaseLifecycleError):
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
)
|
||||
# Reclaim path still works for expired/abandoned after force-expire
|
||||
reclaimed = ll.reclaim_expired_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(reclaimed["outcome"], "reclaimed")
|
||||
self.assertEqual(reclaimed["assignment"]["session_id"], "author-worker-2")
|
||||
|
||||
def test_allocate_payload_includes_consume_identifiers(self) -> None:
|
||||
res = self._controller_allocate(number=907)
|
||||
self.assertIn("consume_allocation", res)
|
||||
c = res["consume_allocation"]
|
||||
for key in (
|
||||
"tool",
|
||||
"lease_id",
|
||||
"assignment_id",
|
||||
"required_role",
|
||||
"required_profile",
|
||||
"required_namespace",
|
||||
"instructions",
|
||||
"handoff_status",
|
||||
):
|
||||
self.assertIn(key, c)
|
||||
self.assertEqual(c["required_namespace"], "gitea-author")
|
||||
self.assertEqual(c["required_profile"], "prgs-author")
|
||||
self.assertIn("gitea_adopt_workflow_lease", c["instructions"])
|
||||
self.assertTrue(res["lease_proof"]["cross_role_handoff"])
|
||||
self.assertEqual(res["lease_proof"]["handoff_status"], "pending")
|
||||
|
||||
def test_same_role_allocation_remains_compatible(self) -> None:
|
||||
res = allocate_next_work(
|
||||
self.db,
|
||||
session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(908)],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
username="author-user",
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertNotIn("consume_allocation", res)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
# No cross-role handoff provenance
|
||||
prov = state.get("provenance") or {}
|
||||
self.assertFalse(prov.get("cross_role_handoff"))
|
||||
# Owner resume still works
|
||||
resume = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(resume["same_owner"])
|
||||
self.assertEqual(resume["outcome"], "adopted_owner_resume")
|
||||
|
||||
def test_inspect_points_required_role_at_consume(self) -> None:
|
||||
res = self._controller_allocate(number=909)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
decision = ll.inspect_lease(
|
||||
self.db, lid, caller_session_id="author-worker"
|
||||
)
|
||||
self.assertEqual(
|
||||
decision["safe_next_action"], ll.SAFE_CONSUME_CROSS_ROLE
|
||||
)
|
||||
self.assertFalse(decision["block"])
|
||||
self.assertEqual(decision["required_role"], ROLE_AUTHOR)
|
||||
|
||||
def test_db_cas_rejects_concurrent_second_consume(self) -> None:
|
||||
res = self._controller_allocate(number=910)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# First consume via DB layer directly
|
||||
first = self.db.adopt_lease(
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
provenance={
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "adopted",
|
||||
"required_role": "author",
|
||||
},
|
||||
)
|
||||
self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
|
||||
# Second CAS must fail
|
||||
with self.assertRaises(ForeignLeaseError):
|
||||
self.db.adopt_lease(
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
provenance={
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"required_role": "author",
|
||||
},
|
||||
)
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
|
||||
|
||||
class MCPBoundaryAdoptRoleBindingTest(unittest.TestCase):
|
||||
"""#843 F1: MCP-boundary role binding for ``gitea_adopt_workflow_lease``.
|
||||
|
||||
The library-level wrong-role test calls ``lease_lifecycle.adopt_lease``
|
||||
directly. These tests prove the MCP entry point derives the adopter role
|
||||
authoritatively from the active authenticated profile and rejects any
|
||||
caller-supplied role that disagrees, so a reviewer/merger profile cannot
|
||||
consume an author handoff by passing ``role="author"``.
|
||||
"""
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
MERGER_PROFILE = {
|
||||
"profile_name": "prgs-merger",
|
||||
"role": "merger",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
FOREIGN_AUTHOR_PROFILE = {
|
||||
"profile_name": "dadeschools-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
self.db.upsert_session(
|
||||
session_id="ctrl-session",
|
||||
role="controller",
|
||||
profile="prgs-controller",
|
||||
pid=99999999,
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.wt = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _ready_issue(self, number: int) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
labels=("status:ready", "type:bug"),
|
||||
title="handoff target",
|
||||
priority=20,
|
||||
)
|
||||
|
||||
def _handoff_lease(self, number: int = 843) -> str:
|
||||
res = allocate_next_work(
|
||||
db=self.db,
|
||||
session_id="ctrl-session",
|
||||
role=ROLE_CONTROLLER,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(number)],
|
||||
apply=True,
|
||||
profile_name="prgs-controller",
|
||||
username="controller-user",
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
return res["assignment"]["lease_id"]
|
||||
|
||||
def _call_adopt_tool(self, profile: dict, **kwargs):
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
with (
|
||||
patch.object(mcp_server, "get_profile", return_value=profile),
|
||||
patch.object(
|
||||
mcp_server,
|
||||
"_control_plane_db_or_error",
|
||||
return_value=(self.db, []),
|
||||
),
|
||||
):
|
||||
return mcp_server.gitea_adopt_workflow_lease(
|
||||
remote="prgs", **kwargs
|
||||
)
|
||||
|
||||
def _assert_handoff_untouched(self, lease_id: str) -> None:
|
||||
state = self.db.get_lease_workflow_state(lease_id)
|
||||
self.assertEqual(state["lease"]["session_id"], "ctrl-session")
|
||||
self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
|
||||
self.assertEqual(state["lease"]["status"], "active")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "pending")
|
||||
|
||||
def test_reviewer_profile_cannot_consume_author_handoff_via_role_author(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(920)
|
||||
result = self._call_adopt_tool(
|
||||
self.REVIEWER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="reviewer-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "reviewer")
|
||||
self.assertEqual(result["supplied_role"], "author")
|
||||
self.assertIn("does not match", result["reasons"][0])
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_merger_profile_cannot_consume_author_handoff_via_role_author(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(921)
|
||||
result = self._call_adopt_tool(
|
||||
self.MERGER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="merger-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "merger")
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_reviewer_profile_rejected_without_role_argument(self) -> None:
|
||||
"""Even without a spoofed role, the profile-derived role binds."""
|
||||
lid = self._handoff_lease(922)
|
||||
result = self._call_adopt_tool(
|
||||
self.REVIEWER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="reviewer-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertIn("wrong role", result["reasons"][0].lower())
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_author_profile_mismatching_supplied_role_rejected(self) -> None:
|
||||
lid = self._handoff_lease(923)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
role="reviewer",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "author")
|
||||
self.assertEqual(result["supplied_role"], "reviewer")
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_foreign_profile_name_rejected_for_author_handoff(self) -> None:
|
||||
"""Provenance required_profile binds even when the role matches."""
|
||||
lid = self._handoff_lease(924)
|
||||
result = self._call_adopt_tool(
|
||||
self.FOREIGN_AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="foreign-author-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertIn("wrong profile", result["reasons"][0].lower())
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_author_profile_consumes_author_handoff(self) -> None:
|
||||
lid = self._handoff_lease(925)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(result["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(result["adopted_from_session_id"], "ctrl-session")
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(
|
||||
state["lease"]["adopted_by_session_id"], "author-worker"
|
||||
)
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "adopted")
|
||||
|
||||
def test_author_profile_consumes_author_handoff_without_role_argument(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(926)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["role"], "author")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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", "[email protected]")
|
||||
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()
|
||||
@@ -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", "[email protected]")
|
||||
_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", "[email protected]")
|
||||
_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()
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Dead-session recovery for cross-session PR base-syncs (#872).
|
||||
|
||||
Validates that when a sanctioned server-side base-sync (``gitea_update_pr_branch_by_merge``)
|
||||
advances a PR's remote head from A to B (a merge commit whose first parent is A),
|
||||
a subsequent author session whose local worktree is at A can recover the dead-session
|
||||
lock via HEAD_RELATION_REMOTE_MERGE_SYNCED and execute a second base-sync (B to C)
|
||||
without deadlock or RuntimeError.
|
||||
"""
|
||||
|
||||
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 = 8720
|
||||
PR_NUMBER = 8721
|
||||
BRANCH = f"fix/issue-{ISSUE}-dead-session-two-base-syncs"
|
||||
IDENTITY = "example-author"
|
||||
PROFILE = "prgs-author"
|
||||
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_two_base_sync_repo(tmp: str) -> dict:
|
||||
"""Build a repo simulating two sequential base-sync merges."""
|
||||
_git(tmp, "init", "-q", "-b", "master")
|
||||
_git(tmp, "config", "user.email", "[email protected]")
|
||||
_git(tmp, "config", "user.name", "Author")
|
||||
Path(tmp, "base.txt").write_text("base 1\n")
|
||||
_git(tmp, "add", "-A")
|
||||
_git(tmp, "commit", "-q", "-m", "initial master")
|
||||
|
||||
# Feature branch cut from initial master -> Head A (prior/recorded head)
|
||||
_git(tmp, "checkout", "-q", "-b", BRANCH)
|
||||
Path(tmp, "feature.txt").write_text("feature work\n")
|
||||
_git(tmp, "add", "-A")
|
||||
_git(tmp, "commit", "-q", "-m", "feature commit A")
|
||||
head_a = _rev(tmp)
|
||||
|
||||
# Master advances -> Master 1
|
||||
_git(tmp, "checkout", "-q", "master")
|
||||
Path(tmp, "base.txt").write_text("base 1\nbase 2\n")
|
||||
_git(tmp, "add", "-A")
|
||||
_git(tmp, "commit", "-q", "-m", "master advance 1")
|
||||
master_1 = _rev(tmp)
|
||||
|
||||
# First sync: merge master into feature -> Head B (merge commit, first parent = A)
|
||||
_git(tmp, "checkout", "-q", BRANCH)
|
||||
_git(tmp, "merge", "-q", "--no-ff", "-m", "First base-sync (merge master)", "master")
|
||||
head_b = _rev(tmp)
|
||||
|
||||
# Master advances again -> Master 2
|
||||
_git(tmp, "checkout", "-q", "master")
|
||||
Path(tmp, "base.txt").write_text("base 1\nbase 2\nbase 3\n")
|
||||
_git(tmp, "add", "-A")
|
||||
_git(tmp, "commit", "-q", "-m", "master advance 2")
|
||||
master_2 = _rev(tmp)
|
||||
|
||||
# Second sync: merge master into feature -> Head C (merge commit, first parent = B)
|
||||
_git(tmp, "checkout", "-q", BRANCH)
|
||||
_git(tmp, "merge", "-q", "--no-ff", "-m", "Second base-sync (merge master)", "master")
|
||||
head_c = _rev(tmp)
|
||||
|
||||
# Non-merge rebase/force-pushed branch shape
|
||||
_git(tmp, "checkout", "-q", "-b", "rebased-branch", head_a)
|
||||
Path(tmp, "rebase.txt").write_text("rebased\n")
|
||||
_git(tmp, "add", "-A")
|
||||
_git(tmp, "commit", "-q", "-m", "rebased commit")
|
||||
rebased_head = _rev(tmp)
|
||||
|
||||
# Reset worktree back to head A, as a dead session leaving local worktree at A
|
||||
_git(tmp, "checkout", "-q", BRANCH)
|
||||
_git(tmp, "reset", "-q", "--hard", head_a)
|
||||
|
||||
return {
|
||||
"head_a": head_a,
|
||||
"master_1": master_1,
|
||||
"head_b": head_b,
|
||||
"master_2": master_2,
|
||||
"head_c": head_c,
|
||||
"rebased_head": rebased_head,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestDeadSessionTwoBaseSyncs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.shas = build_two_base_sync_repo(self.tmp)
|
||||
self.head_a = self.shas["head_a"]
|
||||
self.head_b = self.shas["head_b"]
|
||||
self.head_c = self.shas["head_c"]
|
||||
|
||||
def test_first_base_sync_dead_session_recovery(self):
|
||||
"""AC1: dead session after first base sync (remote=B, local=A) recovers via merge-sync."""
|
||||
lock = make_dead_lock(self.tmp, synced_pr_head=self.head_b)
|
||||
obs = issue_lock_worktree.read_merge_sync_provenance(
|
||||
self.tmp,
|
||||
prior_head_sha=self.head_a,
|
||||
synced_head_sha=self.head_b,
|
||||
remote=REMOTE,
|
||||
)
|
||||
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"])
|
||||
|
||||
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=self.head_a,
|
||||
remote_head_sha=self.head_b,
|
||||
pr_head_sha=self.head_b,
|
||||
pr_number=PR_NUMBER,
|
||||
competing_live_locks=[],
|
||||
candidate_branches=[BRANCH],
|
||||
current_pid=os.getpid(),
|
||||
remote_branch_exists=True,
|
||||
sync_provenance=obs,
|
||||
)
|
||||
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.head_b)
|
||||
|
||||
def test_second_base_sync_head_refresh_after_recovery(self):
|
||||
"""AC2: after recovery, second base sync (remote B -> C) updates durable lock head."""
|
||||
lock_dir = tempfile.mkdtemp()
|
||||
lock_data = make_dead_lock(self.tmp, synced_pr_head=self.head_b)
|
||||
# Rebind to current PID as gitea_lock_issue does upon sanctioned recovery
|
||||
lock_data["session_pid"] = os.getpid()
|
||||
lock_data["pid"] = os.getpid()
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.lock_file_path(
|
||||
remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir
|
||||
),
|
||||
lock_data,
|
||||
)
|
||||
|
||||
refresh_res = issue_lock_store.apply_durable_lock_head_refresh(
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
branch_name=BRANCH,
|
||||
worktree_path=self.tmp,
|
||||
pr_number=PR_NUMBER,
|
||||
identity=IDENTITY,
|
||||
profile=PROFILE,
|
||||
current_pid=os.getpid(),
|
||||
expected_old_head=self.head_b,
|
||||
new_head=self.head_c,
|
||||
synced_at=future_ts(0),
|
||||
base_head=self.shas["master_2"],
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
self.assertTrue(refresh_res["refreshed"], refresh_res["reasons"])
|
||||
self.assertTrue(refresh_res["read_after_write_ok"])
|
||||
loaded = issue_lock_store.load_issue_lock(
|
||||
remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir
|
||||
)
|
||||
self.assertEqual(loaded.get("synced_pr_head"), self.head_c)
|
||||
|
||||
def test_dirty_worktree_blocks_recovery(self):
|
||||
"""AC3: tracked dirty edits block dead-session recovery."""
|
||||
lock = make_dead_lock(self.tmp)
|
||||
obs = issue_lock_worktree.read_merge_sync_provenance(
|
||||
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.head_b
|
||||
)
|
||||
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=" M feature.txt\n",
|
||||
head_sha=self.head_a,
|
||||
remote_head_sha=self.head_b,
|
||||
pr_head_sha=self.head_b,
|
||||
pr_number=PR_NUMBER,
|
||||
competing_live_locks=[],
|
||||
candidate_branches=[BRANCH],
|
||||
current_pid=os.getpid(),
|
||||
remote_branch_exists=True,
|
||||
sync_provenance=obs,
|
||||
)
|
||||
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
|
||||
self.assertTrue(any("dirty" in r or "tracked" in r for r in res["reasons"]))
|
||||
|
||||
def test_foreign_reclaimer_blocks_recovery(self):
|
||||
"""AC4: a foreign claimant cannot recover a dead-session lock."""
|
||||
lock = make_dead_lock(self.tmp)
|
||||
obs = issue_lock_worktree.read_merge_sync_provenance(
|
||||
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.head_b
|
||||
)
|
||||
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="intruder-user",
|
||||
profile=PROFILE,
|
||||
current_branch=BRANCH,
|
||||
porcelain_status="",
|
||||
head_sha=self.head_a,
|
||||
remote_head_sha=self.head_b,
|
||||
pr_head_sha=self.head_b,
|
||||
pr_number=PR_NUMBER,
|
||||
competing_live_locks=[],
|
||||
candidate_branches=[BRANCH],
|
||||
current_pid=os.getpid(),
|
||||
remote_branch_exists=True,
|
||||
sync_provenance=obs,
|
||||
)
|
||||
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
|
||||
|
||||
def test_non_merge_rebased_remote_head_blocks_recovery(self):
|
||||
"""AC5: a rebased/force-pushed remote head (not a merge commit) is refused."""
|
||||
lock = make_dead_lock(self.tmp)
|
||||
obs = issue_lock_worktree.read_merge_sync_provenance(
|
||||
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.shas["rebased_head"]
|
||||
)
|
||||
self.assertFalse(obs["is_merge_sync"])
|
||||
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=self.head_a,
|
||||
remote_head_sha=self.shas["rebased_head"],
|
||||
pr_head_sha=self.shas["rebased_head"],
|
||||
pr_number=PR_NUMBER,
|
||||
competing_live_locks=[],
|
||||
candidate_branches=[BRANCH],
|
||||
current_pid=os.getpid(),
|
||||
remote_branch_exists=True,
|
||||
sync_provenance=obs,
|
||||
)
|
||||
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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(
|
||||
|
||||
@@ -78,6 +78,95 @@ class TestBlockReasonsAndReport(unittest.TestCase):
|
||||
self.assertTrue(report["recovery"])
|
||||
|
||||
|
||||
class TestLiveRemoteParity(unittest.TestCase):
|
||||
"""#610: parity must account for the live remote master, not just local.
|
||||
|
||||
The daemon can be stale relative to the live remote target while the local
|
||||
checkout HEAD still matches the daemon's startup commit, so local parity
|
||||
reports green even though a mutation would run against outdated code.
|
||||
"""
|
||||
|
||||
SHA_C = "c" * 40
|
||||
|
||||
def test_distinguishes_three_shas(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertEqual(res["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(res["local_head"], SHA_A)
|
||||
self.assertEqual(res["live_remote_head"], SHA_B)
|
||||
|
||||
def test_mutation_safe_only_when_all_three_match(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
|
||||
self.assertTrue(res["mutation_safe"])
|
||||
self.assertTrue(res["live_known"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
|
||||
def test_live_stale_when_remote_advanced_past_daemon(self):
|
||||
# Local checkout still matches the daemon start (local parity green),
|
||||
# but the live remote master has advanced -> daemon is live-stale.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(res["in_parity"]) # local parity still green
|
||||
self.assertTrue(res["live_stale"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
|
||||
|
||||
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
|
||||
# Non-goal: unfetchable live remote must not be treated as stale for
|
||||
# read-only, but a mutation-safe claim fails closed.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
|
||||
self.assertFalse(res["live_known"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertTrue(res["in_parity"])
|
||||
|
||||
def test_default_live_remote_preserves_legacy_shape(self):
|
||||
# Callers that do not supply a live head keep the pre-#610 behavior:
|
||||
# in-parity, not live-stale, no live-derived block.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
|
||||
class TestLiveStaleBlockAndReport(unittest.TestCase):
|
||||
"""#610: live-staleness must block mutations and surface a typed blocker."""
|
||||
|
||||
def test_live_stale_produces_block_reasons(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(mp.parity_block_reasons(res))
|
||||
|
||||
def test_disable_env_suppresses_live_stale_block(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
def test_resolver_disagreement_returns_typed_blocker(self):
|
||||
# Parity says local-green, resolver says restart required -> disagreement
|
||||
# is a typed, fail-closed blocker naming the resolver as authoritative.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
|
||||
self.assertIsNotNone(blocker)
|
||||
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
|
||||
self.assertTrue(blocker["restart_required"])
|
||||
self.assertTrue(blocker["resolver_authoritative"])
|
||||
|
||||
def test_no_disagreement_when_resolver_agrees(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertIsNone(
|
||||
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
|
||||
|
||||
def test_live_stale_report_names_live_remote(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
report = mp.parity_report(res)
|
||||
self.assertEqual(report["live_remote_head"], SHA_B)
|
||||
self.assertTrue(report["restart_required"])
|
||||
|
||||
|
||||
class TestReadGitHead(unittest.TestCase):
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
@@ -95,6 +184,149 @@ class TestReadGitHead(unittest.TestCase):
|
||||
self.assertIsNone(mp.read_git_head(""))
|
||||
|
||||
|
||||
class TestReadRemoteMasterHead(unittest.TestCase):
|
||||
"""#610: live remote master head reader (env-overridable, fails to None)."""
|
||||
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
|
||||
|
||||
def test_blank_override_is_none(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
|
||||
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
|
||||
|
||||
def test_unfetchable_remote_is_none(self):
|
||||
# No override; a bogus root/remote must fail closed to None, never raise.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertIsNone(
|
||||
mp.read_remote_master_head("/nonexistent", remote="nope"))
|
||||
|
||||
|
||||
class TestRemoteHeadCache(unittest.TestCase):
|
||||
"""#610: live remote reads are cached with a TTL to stay off the network.
|
||||
|
||||
The parity gate runs on every mutation and every runtime-context read, so an
|
||||
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# These cases intentionally exercise the subprocess/cache path, so they
|
||||
# opt out of suite-wide hermetic mode (PR #788 F1).
|
||||
self._saved_hermetic = mp.hermetic_test_mode()
|
||||
mp.set_hermetic_test_mode(False)
|
||||
mp._clear_remote_head_cache()
|
||||
env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
|
||||
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE,
|
||||
"PYTEST_CURRENT_TEST")
|
||||
}
|
||||
# Allow the probe path under hermetic defenses while still mocking
|
||||
# subprocess so no real network call runs.
|
||||
env[mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE] = "1"
|
||||
self._env = patch.dict(os.environ, env, clear=True)
|
||||
self._env.start()
|
||||
self.addCleanup(self._env.stop)
|
||||
self.addCleanup(mp._clear_remote_head_cache)
|
||||
self.addCleanup(
|
||||
lambda: mp.set_hermetic_test_mode(self._saved_hermetic)
|
||||
)
|
||||
|
||||
def _fake_run(self, sha):
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = f"{sha}\trefs/heads/master\n"
|
||||
calls = {"n": 0}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return _R()
|
||||
return run, calls
|
||||
|
||||
def test_second_call_within_ttl_uses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
self.assertEqual(a, SHA_B)
|
||||
self.assertEqual(b, SHA_B)
|
||||
self.assertEqual(calls["n"], 1)
|
||||
|
||||
def test_zero_ttl_bypasses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
self.assertEqual(calls["n"], 2)
|
||||
|
||||
def test_env_override_never_touches_subprocess(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
self.assertEqual(
|
||||
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
|
||||
self.assertEqual(calls["n"], 0)
|
||||
|
||||
|
||||
class TestHermeticLiveRemoteReads(unittest.TestCase):
|
||||
"""#610 / PR #788 F1/F2: suite hermetic mode never hits the network."""
|
||||
|
||||
def setUp(self):
|
||||
self._saved = mp.hermetic_test_mode()
|
||||
mp.set_hermetic_test_mode(True)
|
||||
mp._clear_remote_head_cache()
|
||||
self.addCleanup(lambda: mp.set_hermetic_test_mode(self._saved))
|
||||
self.addCleanup(mp._clear_remote_head_cache)
|
||||
|
||||
def test_hermetic_mode_returns_none_without_subprocess(self):
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("ls-remote must not run under hermetic mode")
|
||||
|
||||
env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k not in (mp.ENV_TEST_LIVE_REMOTE_HEAD,
|
||||
mp.ENV_TEST_ALLOW_LIVE_REMOTE_PROBE)
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertIsNone(
|
||||
mp.read_remote_master_head("/repo", remote="prgs")
|
||||
)
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
def test_hermetic_mode_survives_clear_true_env(self):
|
||||
"""Module flag, not env pin: clear=True cannot re-enable the probe."""
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("ls-remote must not run after clear=True")
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertIsNone(mp.read_remote_master_head("/repo"))
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
def test_explicit_override_still_wins_under_hermetic(self):
|
||||
run_calls = {"n": 0}
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
run_calls["n"] += 1
|
||||
raise AssertionError("override must bypass subprocess")
|
||||
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
with patch.object(mp.subprocess, "run", boom):
|
||||
self.assertEqual(
|
||||
mp.read_remote_master_head("/repo"), SHA_B
|
||||
)
|
||||
self.assertEqual(run_calls["n"], 0)
|
||||
|
||||
|
||||
class TestServerWiring(unittest.TestCase):
|
||||
"""Integration with the gate choke point in the server namespace."""
|
||||
|
||||
@@ -105,6 +337,13 @@ class TestServerWiring(unittest.TestCase):
|
||||
self._saved = self.srv._STARTUP_PARITY
|
||||
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
||||
"startup_head": SHA_A}
|
||||
# Keep the live-remote read hermetic (no real ls-remote network call):
|
||||
# default the live master to the daemon start so parity is fully green
|
||||
# unless a test overrides the live head explicitly (#610).
|
||||
self._live_patch = patch.dict(
|
||||
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
|
||||
self._live_patch.start()
|
||||
self.addCleanup(self._live_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.srv._STARTUP_PARITY = self._saved
|
||||
@@ -147,6 +386,36 @@ class TestServerWiring(unittest.TestCase):
|
||||
self.assertTrue(out["in_parity"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
# --- #610: live-remote wiring -------------------------------------------
|
||||
|
||||
def test_live_stale_blocks_mutation_though_local_green(self):
|
||||
# Local checkout matches the daemon start (local parity green) but the
|
||||
# live remote master has advanced -> mutations must fail closed.
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
||||
self.assertTrue(
|
||||
self.srv._master_parity_block("gitea.pr.create"))
|
||||
|
||||
def test_assess_tool_exposes_three_distinct_shas(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertEqual(out["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(out["local_head"], SHA_A)
|
||||
self.assertEqual(out["live_remote_head"], SHA_B)
|
||||
self.assertTrue(out["live_stale"])
|
||||
self.assertFalse(out["mutation_safe"])
|
||||
self.assertIn("report", out)
|
||||
|
||||
def test_assess_tool_mutation_safe_when_all_three_match(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertTrue(out["mutation_safe"])
|
||||
self.assertFalse(out["live_stale"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"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([
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"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()
|
||||
@@ -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()
|
||||
@@ -139,6 +139,11 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
# #812 AC20: publishing an unpublished local head is author-only for the
|
||||
# same reason every other push is — it writes a branch to the remote.
|
||||
"publish_unpublished_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Unit and integration tests for Model Usage & Performance Analytics (#651)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
import control_plane_db
|
||||
from webui.analytics_loader import (
|
||||
ANALYTICS_SCHEMA_VERSION,
|
||||
compute_percentile,
|
||||
load_analytics,
|
||||
record_usage,
|
||||
)
|
||||
from webui.app import create_app
|
||||
from webui import console_redaction
|
||||
|
||||
|
||||
class AnalyticsLoaderTest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.temp_dir.name, "test_control_plane.sqlite3")
|
||||
os.environ["GITEA_CONTROL_PLANE_DB"] = self.db_path
|
||||
self.db = control_plane_db.ControlPlaneDB(db_path=self.db_path)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_compute_percentile(self) -> None:
|
||||
self.assertIsNone(compute_percentile([], 50.0))
|
||||
self.assertEqual(compute_percentile([100], 50.0), 100.0)
|
||||
|
||||
# 2 elements: [100, 200]
|
||||
self.assertEqual(compute_percentile([100, 200], 50.0), 150.0)
|
||||
|
||||
# 100 elements: 1..100
|
||||
vals = list(range(1, 101))
|
||||
self.assertEqual(compute_percentile(vals, 50.0), 50.5)
|
||||
self.assertAlmostEqual(compute_percentile(vals, 90.0), 90.1)
|
||||
|
||||
def test_record_and_aggregate_usage(self) -> None:
|
||||
# Record event 1 (complete data)
|
||||
u1 = record_usage(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role="author",
|
||||
model="gemini-3.6-flash",
|
||||
issue_number=651,
|
||||
stage="implementation",
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
estimated_cost_usd=0.0015,
|
||||
latency_ms=200,
|
||||
duration_ms=3000,
|
||||
metadata={"secret_key": "secret123", "note": "token=secret123"},
|
||||
)
|
||||
self.assertGreater(u1, 0)
|
||||
|
||||
# Record event 2 (missing tokens and cost -> unknown)
|
||||
u2 = record_usage(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role="reviewer",
|
||||
model="claude-3-5-sonnet",
|
||||
pr_number=846,
|
||||
stage="review",
|
||||
latency_ms=500,
|
||||
duration_ms=6000,
|
||||
)
|
||||
self.assertGreater(u2, u1)
|
||||
|
||||
snapshot = load_analytics(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
|
||||
self.assertTrue(snapshot.ok)
|
||||
self.assertEqual(snapshot.schema_version, ANALYTICS_SCHEMA_VERSION)
|
||||
self.assertEqual(snapshot.total_events, 2)
|
||||
|
||||
# Verify overall summary
|
||||
summary = snapshot.overall_summary
|
||||
self.assertEqual(summary.total_events, 2)
|
||||
self.assertEqual(summary.events_with_tokens, 1)
|
||||
self.assertEqual(summary.total_tokens, 1500)
|
||||
self.assertEqual(summary.events_with_cost, 1)
|
||||
self.assertEqual(summary.estimated_cost_usd, 0.0015)
|
||||
self.assertEqual(summary.events_with_latency, 2)
|
||||
self.assertEqual(summary.latency_p50_ms, 350.0)
|
||||
|
||||
# Verify missing data handling (AC 3: not zero-fabricated)
|
||||
reviewer_model = snapshot.by_model.get("claude-3-5-sonnet")
|
||||
self.assertIsNotNone(reviewer_model)
|
||||
self.assertEqual(reviewer_model.total_events, 1)
|
||||
self.assertEqual(reviewer_model.events_with_tokens, 0)
|
||||
self.assertIsNone(reviewer_model.total_tokens)
|
||||
self.assertEqual(reviewer_model.display_tokens, "Unknown")
|
||||
self.assertEqual(reviewer_model.events_with_cost, 0)
|
||||
self.assertIsNone(reviewer_model.estimated_cost_usd)
|
||||
self.assertEqual(reviewer_model.display_cost, "Unknown")
|
||||
|
||||
# Verify redaction (AC 4)
|
||||
e1 = [e for e in snapshot.events if e.usage_id == u1][0]
|
||||
self.assertIsNotNone(e1.metadata)
|
||||
self.assertNotIn("secret123", e1.metadata)
|
||||
self.assertIn("[REDACTED]", e1.metadata)
|
||||
|
||||
def test_missing_db_fail_soft(self) -> None:
|
||||
invalid_path = "/nonexistent_path_dir/db.sqlite3"
|
||||
snapshot = load_analytics(db_path=invalid_path)
|
||||
self.assertFalse(snapshot.ok)
|
||||
self.assertIn("control_plane_db_unavailable", snapshot.reason)
|
||||
self.assertEqual(snapshot.overall_summary.display_tokens, "Unknown")
|
||||
|
||||
|
||||
class AnalyticsWebUITest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.temp_dir.name, "test_webui.sqlite3")
|
||||
os.environ["GITEA_CONTROL_PLANE_DB"] = self.db_path
|
||||
self.app = create_app()
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
record_usage(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role="author",
|
||||
model="gemini-3.6-flash",
|
||||
issue_number=651,
|
||||
stage="implementation",
|
||||
input_tokens=2000,
|
||||
output_tokens=1000,
|
||||
estimated_cost_usd=0.003,
|
||||
latency_ms=150,
|
||||
duration_ms=2500,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_analytics_html_route(self) -> None:
|
||||
response = self.client.get("/analytics")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Model Usage & Performance Analytics", response.text)
|
||||
self.assertIn("gemini-3.6-flash", response.text)
|
||||
self.assertIn("3,000", response.text)
|
||||
|
||||
def test_analytics_api_route(self) -> None:
|
||||
response = self.client.get("/api/v1/analytics")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(data["total_events"], 1)
|
||||
self.assertIn("gemini-3.6-flash", data["by_model"])
|
||||
|
||||
def test_analytics_ingest_unauthorized_denied(self) -> None:
|
||||
"""F2: unauthenticated POST must not write the control-plane DB."""
|
||||
payload = {
|
||||
"remote": "dadeschools",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"role": "reviewer",
|
||||
"model": "claude-3-5-sonnet",
|
||||
"pr_number": 846,
|
||||
"stage": "review",
|
||||
"input_tokens": 500,
|
||||
"output_tokens": 100,
|
||||
"latency_ms": 400,
|
||||
"metadata": "Review note token=secret456",
|
||||
}
|
||||
response = self.client.post("/api/v1/analytics/usage", json=payload)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
res_json = response.json()
|
||||
self.assertFalse(res_json.get("ok", True))
|
||||
self.assertEqual(res_json.get("error"), "unauthorized")
|
||||
authorization = res_json.get("authorization") or {}
|
||||
self.assertFalse(authorization.get("allowed"))
|
||||
self.assertFalse(authorization.get("execution_enabled"))
|
||||
|
||||
# No new row written
|
||||
res2 = self.client.get("/api/v1/analytics")
|
||||
self.assertEqual(res2.status_code, 200)
|
||||
self.assertEqual(res2.json()["total_events"], 1)
|
||||
|
||||
def test_html_escapes_script_bearing_model_role_stage(self) -> None:
|
||||
"""F1: stored XSS — dynamic model/role/stage must render escaped."""
|
||||
xss = '<script>alert(1)</script>'
|
||||
record_usage(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role=xss,
|
||||
model=xss,
|
||||
stage=xss,
|
||||
issue_number=999,
|
||||
status="success",
|
||||
)
|
||||
response = self.client.get("/analytics")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Raw tag must not appear; escaped form must.
|
||||
self.assertNotIn("<script>alert(1)</script>", response.text)
|
||||
self.assertIn("<script>alert(1)</script>", response.text)
|
||||
|
||||
def test_load_analytics_coerces_none_scope(self) -> None:
|
||||
"""F4: None remote/org/repo become empty strings, never None."""
|
||||
snapshot = load_analytics(db_path=self.db_path, remote=None, org=None, repo=None)
|
||||
self.assertIsInstance(snapshot.remote, str)
|
||||
self.assertIsInstance(snapshot.org, str)
|
||||
self.assertIsInstance(snapshot.repo, str)
|
||||
self.assertEqual(snapshot.remote, "")
|
||||
self.assertEqual(snapshot.org, "")
|
||||
self.assertEqual(snapshot.repo, "")
|
||||
|
||||
def test_usage_events_retention_max_rows(self) -> None:
|
||||
"""F3: record_usage_event enforces USAGE_EVENTS_MAX_ROWS."""
|
||||
db = control_plane_db.ControlPlaneDB(db_path=self.db_path)
|
||||
original_max = db.USAGE_EVENTS_MAX_ROWS
|
||||
try:
|
||||
db.USAGE_EVENTS_MAX_ROWS = 3
|
||||
for i in range(5):
|
||||
db.record_usage_event(
|
||||
remote="dadeschools",
|
||||
org="org",
|
||||
repo="repo",
|
||||
role="author",
|
||||
model=f"model-{i}",
|
||||
stage="test",
|
||||
)
|
||||
rows = db.query_usage_events(limit=100)
|
||||
self.assertLessEqual(len(rows), 3)
|
||||
# Newest three retained
|
||||
models = {r["model"] for r in rows}
|
||||
self.assertEqual(models, {"model-2", "model-3", "model-4"})
|
||||
finally:
|
||||
db.USAGE_EVENTS_MAX_ROWS = original_max
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Documentation acceptance for the web console architecture ADR (#632 / epic #631).
|
||||
|
||||
Enforces the acceptance criteria of issue #632:
|
||||
|
||||
* AC1 — the ADR exists and covers layers, authority, phases, API versioning,
|
||||
and a page map.
|
||||
* AC2 — every #631 child (#632–#651) maps to at least one architectural
|
||||
component.
|
||||
* AC3 — the closed MVP (#425–#436) is stated as foundation, not recreated.
|
||||
* AC4 — forbidden paths are explicit: raw provider incidents as work,
|
||||
browser-held tokens, process-kill recovery.
|
||||
* AC5 — a controller can approve the document without reading chat history.
|
||||
|
||||
Plus the linkage requirement: ``docs/webui-local-dev.md`` cross-links the ADR.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ADR = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "webui-control-plane-console-architecture-adr.md"
|
||||
)
|
||||
ADR_BASENAME = "webui-control-plane-console-architecture-adr.md"
|
||||
LOCAL_DEV = REPO_ROOT / "docs" / "webui-local-dev.md"
|
||||
|
||||
# Epic #631 children, phases 1-4 (twenty capability areas).
|
||||
EPIC_CHILDREN = tuple(f"#{number}" for number in range(632, 652))
|
||||
|
||||
|
||||
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_required_sections():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||
assert "#631" in text and "#632" in text
|
||||
for heading in (
|
||||
"## 2. Decision summary",
|
||||
"## 4. Authority boundaries",
|
||||
"## 5. Request flow and the redaction boundary",
|
||||
"## 6. API naming and versioning",
|
||||
"## 7. Page map",
|
||||
"## 8. Component ownership",
|
||||
"## 9. Phase gates",
|
||||
"## 11. Forbidden paths",
|
||||
):
|
||||
assert heading in text, f"ADR must contain section {heading!r}"
|
||||
assert "browser ui" in lower and "domain loader" in lower
|
||||
assert "control-plane db" in lower and "capability gate" in lower
|
||||
|
||||
|
||||
def test_ac1_api_versioning_is_decided_including_legacy_routes():
|
||||
text = _read(ADR)
|
||||
assert "/api/v1/" in text, "ADR must decide the versioned API prefix"
|
||||
assert "/api/v2/" in text, "ADR must state how breaking changes are handled"
|
||||
lower = text.lower()
|
||||
assert "compatibility alias" in lower, (
|
||||
"ADR must say what happens to the existing unversioned MVP exports"
|
||||
)
|
||||
|
||||
|
||||
def test_ac1_page_map_covers_mvp_routes():
|
||||
text = _read(ADR)
|
||||
for route in ("`/`", "`/health`", "`/projects`", "`/prompts`", "`/runtime`",
|
||||
"`/audit`", "`/actions`"):
|
||||
assert route in text, f"page map must account for MVP route {route}"
|
||||
|
||||
|
||||
def test_ac2_every_epic_child_maps_to_a_component():
|
||||
text = _read(ADR)
|
||||
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
|
||||
missing = [child for child in EPIC_CHILDREN if child not in ownership]
|
||||
assert not missing, (
|
||||
f"epic #631 children without an architectural component: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac2_every_child_row_declares_a_phase():
|
||||
text = _read(ADR)
|
||||
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
|
||||
for child in EPIC_CHILDREN:
|
||||
row = next(
|
||||
(line for line in ownership.splitlines() if line.startswith(f"| {child} ")),
|
||||
None,
|
||||
)
|
||||
assert row is not None, f"no ownership row for {child}"
|
||||
assert row.rstrip().endswith(("| 1 |", "| 2 |", "| 3 |", "| 4 |")), (
|
||||
f"ownership row for {child} must end with its phase: {row!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac3_mvp_is_foundation_not_recreated():
|
||||
text = _read(ADR)
|
||||
assert "#425" in text and "#436" in text
|
||||
lower = text.lower()
|
||||
assert "do not recreate" in lower or "recreating mvp scope" in lower
|
||||
assert "retained and evolved" in lower
|
||||
|
||||
|
||||
def test_ac4_forbidden_paths_are_explicit():
|
||||
text = _read(ADR)
|
||||
forbidden = text.split("## 11. Forbidden paths", 1)[-1].split("## 12.", 1)[0]
|
||||
lower = forbidden.lower()
|
||||
assert "raw provider incidents" in lower and "#612" in forbidden
|
||||
assert "browser-held tokens" in lower
|
||||
assert "process-kill recovery" in lower and "#630" in forbidden
|
||||
assert "ungated browser mutations" in lower
|
||||
|
||||
|
||||
def test_ac5_approval_checklist_is_self_contained():
|
||||
text = _read(ADR)
|
||||
assert "## 12. Approval checklist" in text
|
||||
checklist = text.split("## 12. Approval checklist", 1)[-1].split("## 13.", 1)[0]
|
||||
for marker in ("1.", "2.", "3.", "4.", "5.", "6."):
|
||||
assert marker in checklist, f"approval checklist missing item {marker}"
|
||||
|
||||
|
||||
def test_adr_states_the_two_boundary_invariants():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "no secrets to the browser" in lower
|
||||
assert "no ungated mutations" in lower
|
||||
|
||||
|
||||
def test_open_questions_are_recorded_not_implied():
|
||||
text = _read(ADR)
|
||||
assert "## 13. Open questions and follow-ups" in text
|
||||
section = text.split("## 13. Open questions and follow-ups", 1)[-1]
|
||||
assert "#633" in section, "deferred authorization work must name its issue"
|
||||
|
||||
|
||||
def test_local_dev_doc_cross_links_the_adr():
|
||||
text = _read(LOCAL_DEV)
|
||||
assert ADR_BASENAME in text, (
|
||||
"docs/webui-local-dev.md must cross-link the console architecture ADR "
|
||||
"(issue #632 scope)"
|
||||
)
|
||||
|
||||
|
||||
def test_docs_do_not_embed_secrets():
|
||||
for path in (ADR, LOCAL_DEV):
|
||||
text = _read(path)
|
||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||
assert marker not in text, f"{path.name} contains {marker!r}"
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.audit_validator import audit_report, infer_task_kind
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
"""Console authorization, redaction, and audit model tests (#633).
|
||||
|
||||
Covers each acceptance criterion and each required test named in the issue:
|
||||
|
||||
* AC1 — RBAC matrix and privileged-action list.
|
||||
* AC2 — redaction rules, unit-tested against sample payloads.
|
||||
* AC3 — audit event schema with required fields and retention defaults.
|
||||
* AC4 — Phase 2 integration points.
|
||||
* AC5 — local-dev mode with explicit insecurity warnings.
|
||||
|
||||
Required tests: redaction units (token, keychain, password patterns),
|
||||
default-deny for unauthenticated write stubs, and audit record creation for a
|
||||
simulated privileged preview.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
from task_capability_map import TASK_CAPABILITY_MAP # noqa: E402
|
||||
from webui import console_audit, console_authz # noqa: E402
|
||||
from webui.app import create_app # noqa: E402
|
||||
from webui.console_redaction import ( # noqa: E402
|
||||
REDACTED,
|
||||
redact_payload,
|
||||
redact_text,
|
||||
redaction_policy,
|
||||
scan_for_secrets,
|
||||
)
|
||||
|
||||
DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs"
|
||||
AUTHZ_DOC = DOCS / "webui-authz-audit.md"
|
||||
|
||||
|
||||
def _principal(role: str) -> console_authz.Principal:
|
||||
return console_authz.Principal(
|
||||
subject=f"{role}@example.com",
|
||||
role=role,
|
||||
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||
authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
class TestRoleMatrix(unittest.TestCase):
|
||||
"""AC1 — the written RBAC matrix and privileged-action list."""
|
||||
|
||||
def test_roles_are_ordered_least_to_most_authority(self):
|
||||
self.assertEqual(
|
||||
console_authz.ROLE_ORDER,
|
||||
("viewer", "operator", "controller", "admin"),
|
||||
)
|
||||
|
||||
def test_every_role_has_a_description(self):
|
||||
for role in console_authz.ROLE_ORDER:
|
||||
with self.subTest(role=role):
|
||||
self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip())
|
||||
|
||||
def test_higher_roles_inherit_lower_role_actions(self):
|
||||
matrix = {
|
||||
entry["role"]: set(entry["permitted_actions"])
|
||||
for entry in console_authz.rbac_matrix()["roles"]
|
||||
}
|
||||
for lower, higher in zip(
|
||||
console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:]
|
||||
):
|
||||
with self.subTest(lower=lower, higher=higher):
|
||||
self.assertTrue(matrix[lower].issubset(matrix[higher]))
|
||||
|
||||
def test_viewer_holds_no_write_action(self):
|
||||
matrix = {
|
||||
entry["role"]: set(entry["permitted_actions"])
|
||||
for entry in console_authz.rbac_matrix()["roles"]
|
||||
}
|
||||
self.assertEqual(matrix["viewer"], set())
|
||||
|
||||
def test_privileged_action_list_is_non_empty_and_classified(self):
|
||||
privileged = console_authz.privileged_actions()
|
||||
self.assertTrue(privileged)
|
||||
ids = {action.action_id for action in privileged}
|
||||
# Merge and branch deletion are the canonical privileged pair.
|
||||
self.assertIn("merge_pr", ids)
|
||||
self.assertIn("delete_branch", ids)
|
||||
|
||||
def test_merge_and_delete_require_dual_control_and_break_glass(self):
|
||||
for action_id in ("merge_pr", "delete_branch"):
|
||||
with self.subTest(action=action_id):
|
||||
action = console_authz.get_action(action_id)
|
||||
self.assertTrue(action.dual_control)
|
||||
self.assertTrue(action.break_glass)
|
||||
self.assertTrue(action.requires_confirmation)
|
||||
|
||||
def test_every_write_action_requires_confirmation(self):
|
||||
for action in console_authz.ACTIONS.values():
|
||||
with self.subTest(action=action.action_id):
|
||||
self.assertTrue(action.requires_confirmation)
|
||||
|
||||
def test_delete_branch_is_admin_only(self):
|
||||
self.assertEqual(
|
||||
console_authz.get_action("delete_branch").minimum_role,
|
||||
console_authz.ADMIN,
|
||||
)
|
||||
|
||||
def test_actions_map_to_real_mcp_capability_vocabulary(self):
|
||||
"""The console must not invent an authority the MCP layer lacks."""
|
||||
for action in console_authz.ACTIONS.values():
|
||||
with self.subTest(action=action.action_id):
|
||||
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
|
||||
self.assertEqual(
|
||||
action.mcp_permission,
|
||||
TASK_CAPABILITY_MAP[action.task_key]["permission"],
|
||||
)
|
||||
self.assertEqual(
|
||||
action.mcp_role,
|
||||
TASK_CAPABILITY_MAP[action.task_key]["role"],
|
||||
)
|
||||
|
||||
def test_matrix_declares_deny_by_default_and_execution_disabled(self):
|
||||
matrix = console_authz.rbac_matrix()
|
||||
self.assertEqual(matrix["default_decision"], "deny")
|
||||
self.assertFalse(matrix["execution_enabled"])
|
||||
|
||||
|
||||
class TestAuthorizeDefaultDeny(unittest.TestCase):
|
||||
"""Fail-closed behaviour of the authorization decision."""
|
||||
|
||||
def test_anonymous_is_denied_every_action(self):
|
||||
for action_id in console_authz.ACTIONS:
|
||||
with self.subTest(action=action_id):
|
||||
decision = console_authz.authorize(action_id)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(
|
||||
decision.reason_code, console_authz.DENY_UNAUTHENTICATED
|
||||
)
|
||||
|
||||
def test_unknown_action_is_denied(self):
|
||||
decision = console_authz.authorize(
|
||||
"not_a_real_action", _principal("admin")
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION)
|
||||
|
||||
def test_unknown_role_is_denied(self):
|
||||
rogue = console_authz.Principal(
|
||||
subject="[email protected]",
|
||||
role="superuser",
|
||||
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||
authenticated=True,
|
||||
)
|
||||
decision = console_authz.authorize("comment_issue", rogue)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE)
|
||||
|
||||
def test_insufficient_role_is_denied(self):
|
||||
decision = console_authz.authorize("merge_pr", _principal("operator"))
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(
|
||||
decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE
|
||||
)
|
||||
|
||||
def test_sufficient_role_allows_preview_only(self):
|
||||
decision = console_authz.authorize("merge_pr", _principal("controller"))
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertFalse(decision.execution_enabled)
|
||||
|
||||
def test_execution_is_refused_while_phase_is_not_active(self):
|
||||
decision = console_authz.authorize(
|
||||
"merge_pr", _principal("controller"), for_execution=True
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(
|
||||
decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE
|
||||
)
|
||||
|
||||
def test_allowed_decision_never_reports_execution_enabled(self):
|
||||
for action_id in console_authz.ACTIONS:
|
||||
with self.subTest(action=action_id):
|
||||
decision = console_authz.authorize(
|
||||
action_id, _principal("admin")
|
||||
)
|
||||
self.assertFalse(decision.execution_enabled)
|
||||
|
||||
|
||||
class TestIdentityResolution(unittest.TestCase):
|
||||
"""AC5 — identity sources, including the insecure local-dev mode."""
|
||||
|
||||
def test_no_auth_mode_yields_anonymous_viewer(self):
|
||||
principal = console_authz.resolve_principal(env={})
|
||||
self.assertFalse(principal.authenticated)
|
||||
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||
self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE)
|
||||
|
||||
def test_local_dev_mode_warns_that_identity_is_unverified(self):
|
||||
principal = console_authz.resolve_principal(
|
||||
env={
|
||||
console_authz.AUTH_MODE_ENV: "local-dev",
|
||||
console_authz.DEV_SUBJECT_ENV: "[email protected]",
|
||||
console_authz.DEV_ROLE_ENV: "admin",
|
||||
}
|
||||
)
|
||||
self.assertTrue(principal.authenticated)
|
||||
self.assertEqual(principal.role, "admin")
|
||||
self.assertTrue(principal.warnings)
|
||||
self.assertIn("asserted", " ".join(principal.warnings).lower())
|
||||
|
||||
def test_local_dev_without_subject_falls_back_to_anonymous(self):
|
||||
principal = console_authz.resolve_principal(
|
||||
env={console_authz.AUTH_MODE_ENV: "local-dev"}
|
||||
)
|
||||
self.assertFalse(principal.authenticated)
|
||||
|
||||
def test_local_dev_unknown_role_degrades_to_viewer(self):
|
||||
principal = console_authz.resolve_principal(
|
||||
env={
|
||||
console_authz.AUTH_MODE_ENV: "local_dev",
|
||||
console_authz.DEV_SUBJECT_ENV: "[email protected]",
|
||||
console_authz.DEV_ROLE_ENV: "root",
|
||||
}
|
||||
)
|
||||
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||
|
||||
def test_access_proxy_without_header_fails_closed(self):
|
||||
"""A proxy-mode request that did not traverse the proxy is anonymous."""
|
||||
principal = console_authz.resolve_principal(
|
||||
headers={},
|
||||
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
|
||||
)
|
||||
self.assertFalse(principal.authenticated)
|
||||
|
||||
def test_access_proxy_role_comes_from_server_config_not_client(self):
|
||||
env = {
|
||||
console_authz.AUTH_MODE_ENV: "access_proxy",
|
||||
console_authz.ROLE_MAP_ENV: json.dumps(
|
||||
{"[email protected]": "controller"}
|
||||
),
|
||||
}
|
||||
principal = console_authz.resolve_principal(
|
||||
headers={
|
||||
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]",
|
||||
"x-role": "admin", # client-supplied role must be ignored
|
||||
},
|
||||
env=env,
|
||||
)
|
||||
self.assertEqual(principal.role, "controller")
|
||||
|
||||
def test_access_proxy_unmapped_subject_defaults_to_viewer(self):
|
||||
principal = console_authz.resolve_principal(
|
||||
headers={
|
||||
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"
|
||||
},
|
||||
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
|
||||
)
|
||||
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||
|
||||
def test_malformed_role_map_does_not_raise_and_denies(self):
|
||||
principal = console_authz.resolve_principal(
|
||||
headers={console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"},
|
||||
env={
|
||||
console_authz.AUTH_MODE_ENV: "access_proxy",
|
||||
console_authz.ROLE_MAP_ENV: "{not json",
|
||||
},
|
||||
)
|
||||
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||
|
||||
def test_probe_auth_is_opt_in(self):
|
||||
self.assertFalse(console_authz.probe_auth_required(env={}))
|
||||
self.assertTrue(
|
||||
console_authz.probe_auth_required(
|
||||
env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"}
|
||||
)
|
||||
)
|
||||
|
||||
def test_probe_auth_is_declared_but_not_yet_enforced(self):
|
||||
"""Phase 1 declares the probe-auth policy; no route enforces it yet.
|
||||
|
||||
The flag exists so the Phase 2 action framework has a declared policy
|
||||
to honour instead of inventing a second one. Pinning the current
|
||||
not-enforced status here means wiring it later is a deliberate change
|
||||
that updates this test and the documentation together, rather than a
|
||||
silent behaviour shift. The documentation must say so plainly, because
|
||||
an operator who sets the variable believing it protects a probe is
|
||||
worse off than one who knows it does not.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from webui import app as webui_app
|
||||
|
||||
source = inspect.getsource(webui_app)
|
||||
self.assertNotIn(
|
||||
"probe_auth_required",
|
||||
source,
|
||||
msg=(
|
||||
"webui.app now consults probe_auth_required, so probe auth is "
|
||||
"no longer merely declared. Update the 'Probe authentication' "
|
||||
"section of docs/webui-authz-audit.md, which states it "
|
||||
"enforces nothing, and replace this test with real "
|
||||
"enforcement coverage."
|
||||
),
|
||||
)
|
||||
self.assertIn(
|
||||
"enforces nothing today",
|
||||
AUTHZ_DOC.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
||||
class TestRedaction(unittest.TestCase):
|
||||
"""AC2 — required redaction units: token, keychain, password patterns."""
|
||||
|
||||
def test_token_assignment_is_redacted(self):
|
||||
out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl")
|
||||
self.assertIn(REDACTED, out)
|
||||
self.assertNotIn("abcd1234efgh5678ijkl", out)
|
||||
|
||||
def test_password_assignment_is_redacted(self):
|
||||
out = redact_text("password: hunter2supersecret")
|
||||
self.assertIn(REDACTED, out)
|
||||
self.assertNotIn("hunter2supersecret", out)
|
||||
|
||||
def test_keychain_reference_is_redacted(self):
|
||||
out = redact_text("keychain:gitea-prgs-token")
|
||||
self.assertIn(REDACTED, out)
|
||||
self.assertNotIn("gitea-prgs-token", out)
|
||||
|
||||
def test_keychain_command_is_redacted(self):
|
||||
out = redact_text("security find-generic-password -s gitea -w")
|
||||
self.assertIn(REDACTED, out)
|
||||
self.assertNotIn("find-generic-password -s gitea", out)
|
||||
|
||||
def test_bearer_credential_is_redacted(self):
|
||||
out = redact_text("Authorization: Bearer abcdef1234567890abcdef")
|
||||
self.assertNotIn("abcdef1234567890abcdef", out)
|
||||
|
||||
def test_jwt_is_redacted(self):
|
||||
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop"
|
||||
out = redact_text(f"session={token}")
|
||||
self.assertNotIn(token, out)
|
||||
|
||||
def test_private_key_block_is_redacted(self):
|
||||
pem = (
|
||||
"-----BEGIN RSA PRIVATE KEY-----\n"
|
||||
"MIIEowIBAAKCAQEAsecretmaterial\n"
|
||||
"-----END RSA PRIVATE KEY-----"
|
||||
)
|
||||
out = redact_text(pem)
|
||||
self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out)
|
||||
|
||||
def test_api_key_assignment_is_redacted(self):
|
||||
out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"')
|
||||
self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out)
|
||||
|
||||
def test_nested_payload_is_redacted_recursively(self):
|
||||
payload = {
|
||||
"token": "abc123456789",
|
||||
"nested": {"note": "password=letmein12345"},
|
||||
"list": ["keychain:some-entry"],
|
||||
"safe": "plain text",
|
||||
}
|
||||
out = redact_payload(payload)
|
||||
self.assertEqual(out["token"], REDACTED)
|
||||
self.assertNotIn("letmein12345", json.dumps(out))
|
||||
self.assertNotIn("some-entry", json.dumps(out))
|
||||
self.assertEqual(out["safe"], "plain text")
|
||||
|
||||
def test_scan_reports_findings_before_and_none_after(self):
|
||||
dirty = "password: hunter2supersecret"
|
||||
self.assertTrue(scan_for_secrets(dirty))
|
||||
self.assertEqual(scan_for_secrets(redact_text(dirty)), [])
|
||||
|
||||
def test_non_strings_pass_through_untouched(self):
|
||||
self.assertEqual(redact_text(42), 42)
|
||||
self.assertEqual(
|
||||
redact_payload({"n": 1, "b": True}), {"n": 1, "b": True}
|
||||
)
|
||||
|
||||
def test_policy_is_documented_and_declares_redact_before_persist(self):
|
||||
policy = redaction_policy()
|
||||
self.assertTrue(policy["redact_before_persist"])
|
||||
self.assertIn("audit_records", policy["applies_to"])
|
||||
self.assertTrue(policy["console_rules"])
|
||||
|
||||
def test_policy_statement_contains_no_secret_material(self):
|
||||
self.assertEqual(scan_for_secrets(redaction_policy()), [])
|
||||
|
||||
|
||||
class TestAuditSchema(unittest.TestCase):
|
||||
"""AC3 — audit event schema, required fields, and retention defaults."""
|
||||
|
||||
def _event(self, action_id="merge_pr", **kwargs):
|
||||
return console_audit.build_event(
|
||||
action_id=action_id,
|
||||
result=console_audit.RESULT_DENIED,
|
||||
decision=console_authz.authorize(action_id, _principal("operator")),
|
||||
target={"kind": "pr", "ref": "#123"},
|
||||
request_id="req-test",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_every_required_field_is_present(self):
|
||||
event = self._event()
|
||||
for field in console_audit.REQUIRED_FIELDS:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, event)
|
||||
|
||||
def test_actor_carries_who_and_how_they_were_identified(self):
|
||||
event = self._event()
|
||||
for field in console_audit.REQUIRED_ACTOR_FIELDS:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, event["actor"])
|
||||
|
||||
def test_correlation_ids_are_present(self):
|
||||
event = self._event()
|
||||
for field in console_audit.REQUIRED_CORRELATION_FIELDS:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, event["correlation"])
|
||||
self.assertEqual(event["correlation"]["request_id"], "req-test")
|
||||
self.assertEqual(event["correlation"]["mcp_task"], "merge_pr")
|
||||
|
||||
def test_timestamp_is_timezone_aware_utc_iso8601(self):
|
||||
now = datetime.datetime(
|
||||
2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
event = self._event(now=now)
|
||||
self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00")
|
||||
parsed = datetime.datetime.fromisoformat(event["timestamp"])
|
||||
self.assertIsNotNone(parsed.tzinfo)
|
||||
|
||||
def test_retention_defaults_by_class(self):
|
||||
self.assertEqual(
|
||||
console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90
|
||||
)
|
||||
self.assertEqual(
|
||||
console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED],
|
||||
365,
|
||||
)
|
||||
self.assertEqual(
|
||||
console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS],
|
||||
730,
|
||||
)
|
||||
|
||||
def test_break_glass_action_retains_longest(self):
|
||||
event = self._event("merge_pr")
|
||||
self.assertEqual(
|
||||
event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS
|
||||
)
|
||||
|
||||
def test_routine_write_uses_standard_retention(self):
|
||||
event = self._event("comment_issue")
|
||||
self.assertEqual(
|
||||
event["retention"]["class"], console_audit.RETENTION_STANDARD
|
||||
)
|
||||
|
||||
def test_unknown_action_retains_as_privileged_not_standard(self):
|
||||
"""Conservative direction: keep an unclassifiable record longer."""
|
||||
self.assertEqual(
|
||||
console_audit.retention_class_for(None),
|
||||
console_audit.RETENTION_PRIVILEGED,
|
||||
)
|
||||
|
||||
def test_retention_expiry_matches_declared_days(self):
|
||||
now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc)
|
||||
event = self._event("comment_issue", now=now)
|
||||
expires = datetime.datetime.fromisoformat(
|
||||
event["retention"]["expires_at"]
|
||||
)
|
||||
self.assertEqual((expires - now).days, 90)
|
||||
|
||||
def test_invalid_result_degrades_to_failed(self):
|
||||
event = console_audit.build_event(action_id="merge_pr", result="banana")
|
||||
self.assertEqual(event["result"], console_audit.RESULT_FAILED)
|
||||
|
||||
def test_denied_result_is_representable(self):
|
||||
"""An authorization denial has no MCP-side mutation record."""
|
||||
self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS)
|
||||
|
||||
def test_event_is_redacted_before_it_is_returned(self):
|
||||
event = console_audit.build_event(
|
||||
action_id="merge_pr",
|
||||
result=console_audit.RESULT_DENIED,
|
||||
detail="failed with token=abcdef1234567890",
|
||||
metadata={"password": "hunter2supersecret"},
|
||||
)
|
||||
serialized = json.dumps(event)
|
||||
self.assertNotIn("abcdef1234567890", serialized)
|
||||
self.assertNotIn("hunter2supersecret", serialized)
|
||||
self.assertTrue(event["redacted"])
|
||||
|
||||
def test_audit_policy_reports_schema_and_retention(self):
|
||||
policy = console_audit.audit_policy()
|
||||
self.assertTrue(policy["append_only"])
|
||||
self.assertTrue(policy["redact_before_persist"])
|
||||
self.assertEqual(
|
||||
policy["retention_defaults_days"], console_audit.RETENTION_DAYS
|
||||
)
|
||||
|
||||
|
||||
class TestAuditSink(unittest.TestCase):
|
||||
"""Append-only persistence behaviour."""
|
||||
|
||||
def test_write_is_a_noop_when_sink_is_unconfigured(self):
|
||||
saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
|
||||
try:
|
||||
self.assertFalse(console_audit.audit_enabled())
|
||||
self.assertFalse(console_audit.write_event({"schema_version": 1}))
|
||||
finally:
|
||||
if saved is not None:
|
||||
os.environ[console_audit.AUDIT_LOG_ENV] = saved
|
||||
|
||||
def test_records_append_one_json_line_each(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||
for _ in range(3):
|
||||
event = console_audit.build_event(
|
||||
action_id="merge_pr", result=console_audit.RESULT_DENIED
|
||||
)
|
||||
self.assertTrue(console_audit.write_event(event, path=sink))
|
||||
with open(sink, encoding="utf-8") as handle:
|
||||
lines = [json.loads(line) for line in handle if line.strip()]
|
||||
self.assertEqual(len(lines), 3)
|
||||
self.assertEqual(len({line["event_id"] for line in lines}), 3)
|
||||
|
||||
def test_a_record_that_still_carries_a_secret_is_not_persisted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||
leaky = {
|
||||
"schema_version": 1,
|
||||
"detail": "password: hunter2supersecret",
|
||||
}
|
||||
self.assertFalse(console_audit.write_event(leaky, path=sink))
|
||||
self.assertFalse(os.path.exists(sink))
|
||||
|
||||
def test_write_never_raises_on_a_bad_path(self):
|
||||
self.assertFalse(
|
||||
console_audit.write_event(
|
||||
{"schema_version": 1}, path="/nonexistent-dir/audit.jsonl"
|
||||
)
|
||||
)
|
||||
|
||||
def test_simulated_privileged_preview_creates_an_audit_record(self):
|
||||
"""Required test: audit record creation for a privileged preview."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||
os.environ[console_audit.AUDIT_LOG_ENV] = sink
|
||||
try:
|
||||
decision = console_authz.authorize(
|
||||
"merge_pr", _principal("controller")
|
||||
)
|
||||
outcome = console_audit.record_event(
|
||||
action_id="merge_pr",
|
||||
result=console_audit.RESULT_PREVIEWED,
|
||||
decision=decision,
|
||||
target={"kind": "pr", "ref": "#123"},
|
||||
request_id="req-preview",
|
||||
)
|
||||
finally:
|
||||
os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
|
||||
self.assertTrue(outcome["written"])
|
||||
with open(sink, encoding="utf-8") as handle:
|
||||
record = json.loads(handle.read().strip())
|
||||
self.assertEqual(record["action"], "merge_pr")
|
||||
self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED)
|
||||
self.assertEqual(record["action_class"], "privileged")
|
||||
self.assertTrue(record["decision"]["allowed"])
|
||||
self.assertFalse(record["decision"]["execution_enabled"])
|
||||
self.assertEqual(record["actor"]["role"], "controller")
|
||||
|
||||
def test_decision_block_survives_redaction(self):
|
||||
"""Regression: naming it 'authorization' collided with a secret hint.
|
||||
|
||||
``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the
|
||||
HTTP header), so a block under that key was replaced wholesale by the
|
||||
placeholder and the record lost its decision entirely.
|
||||
"""
|
||||
event = console_audit.build_event(
|
||||
action_id="merge_pr",
|
||||
result=console_audit.RESULT_DENIED,
|
||||
decision=console_authz.authorize("merge_pr", _principal("admin")),
|
||||
)
|
||||
self.assertIsInstance(event["decision"], dict)
|
||||
self.assertIn("allowed", event["decision"])
|
||||
|
||||
|
||||
class TestConsoleRoutes(unittest.TestCase):
|
||||
"""AC4 — the wired Phase 2 integration points, still fail-closed."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app(bind_host="127.0.0.1"))
|
||||
|
||||
def test_unauthenticated_write_stub_is_denied(self):
|
||||
"""Required test: default-deny for unauthenticated write stubs."""
|
||||
response = self.client.post(
|
||||
"/api/actions/merge_pr/attempt", json={"pr_number": 99}
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
body = response.json()
|
||||
self.assertFalse(body["success"])
|
||||
authorization = body["authorization"]
|
||||
self.assertFalse(authorization["allowed"])
|
||||
self.assertEqual(
|
||||
authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED
|
||||
)
|
||||
self.assertFalse(authorization["execution_enabled"])
|
||||
|
||||
def test_preview_reports_an_authorization_decision(self):
|
||||
response = self.client.get("/api/actions/merge_pr/preview?pr_number=7")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
authorization = response.json()["authorization"]
|
||||
self.assertFalse(authorization["allowed"])
|
||||
self.assertTrue(authorization["dual_control"])
|
||||
self.assertEqual(authorization["required_role"], "controller")
|
||||
|
||||
def test_unknown_action_preview_still_404s(self):
|
||||
response = self.client.get("/api/actions/no_such_action/preview")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_security_model_endpoint_publishes_all_three_policies(self):
|
||||
response = self.client.get("/api/console/security-model")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertIn("rbac", body)
|
||||
self.assertIn("redaction", body)
|
||||
self.assertIn("audit", body)
|
||||
self.assertEqual(body["rbac"]["default_decision"], "deny")
|
||||
|
||||
def test_security_model_endpoint_leaks_no_secrets(self):
|
||||
response = self.client.get("/api/console/security-model")
|
||||
self.assertEqual(scan_for_secrets(response.json()), [])
|
||||
|
||||
def test_security_model_rejects_writes(self):
|
||||
response = self.client.post("/api/console/security-model", json={})
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_existing_read_routes_are_unaffected(self):
|
||||
for path in ("/", "/health", "/actions", "/api/actions"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.get(path).status_code, 200)
|
||||
|
||||
|
||||
class TestAuthzAuditDoc(unittest.TestCase):
|
||||
"""The model must be written down, not only coded."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.text = (
|
||||
AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else ""
|
||||
)
|
||||
|
||||
def test_doc_exists(self):
|
||||
self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}")
|
||||
|
||||
def test_doc_covers_each_required_section(self):
|
||||
for heading in (
|
||||
"Identity sources",
|
||||
"Role matrix",
|
||||
"Privileged actions",
|
||||
"Secret redaction",
|
||||
"Audit event schema",
|
||||
"Retention",
|
||||
"Phase 2 integration",
|
||||
"Local-dev mode",
|
||||
):
|
||||
with self.subTest(heading=heading):
|
||||
self.assertIn(heading, self.text)
|
||||
|
||||
def test_doc_names_every_role(self):
|
||||
for role in console_authz.ROLE_ORDER:
|
||||
with self.subTest(role=role):
|
||||
self.assertIn(role, self.text)
|
||||
|
||||
def test_doc_names_every_console_action(self):
|
||||
for action_id in console_authz.ACTIONS:
|
||||
with self.subTest(action=action_id):
|
||||
self.assertIn(action_id, self.text)
|
||||
|
||||
def test_doc_states_retention_defaults(self):
|
||||
for days in console_audit.RETENTION_DAYS.values():
|
||||
with self.subTest(days=days):
|
||||
self.assertIn(str(days), self.text)
|
||||
|
||||
def test_doc_warns_local_dev_is_insecure(self):
|
||||
self.assertIn("INSECURE", self.text.upper())
|
||||
|
||||
def test_doc_states_default_deny(self):
|
||||
self.assertIn("deny", self.text.lower())
|
||||
|
||||
def test_doc_contains_no_secret_material(self):
|
||||
self.assertEqual(scan_for_secrets(self.text), [])
|
||||
|
||||
def test_deployment_doc_links_to_the_model(self):
|
||||
deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8")
|
||||
self.assertIn("webui-authz-audit", deployment)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.deployment_boundary import (
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from task_capability_map import TASK_CAPABILITY_MAP
|
||||
from webui.app import create_app
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Tests for the unified web-console inventory API (#636).
|
||||
|
||||
Covers the four cases the issue names — empty, populated, partial failure, and
|
||||
the no-false-unowned invariant — plus redaction, collision detection, the
|
||||
resource-split routes, and read-only guarantees against a real control-plane
|
||||
database and real durable lock files.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
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))
|
||||
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
import control_plane_db
|
||||
from webui.app import create_app
|
||||
from webui import inventory
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _write_lock(lock_dir: str, name: str, payload: dict) -> str:
|
||||
path = os.path.join(lock_dir, name)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
return path
|
||||
|
||||
|
||||
def _live_lock_payload(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch: str,
|
||||
worktree_path: str,
|
||||
pid: int,
|
||||
username: str = "jcwalker3",
|
||||
profile: str = "prgs-author",
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
future = now + timedelta(hours=2)
|
||||
return {
|
||||
"branch_name": branch,
|
||||
"issue_number": issue_number,
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"remote": "prgs",
|
||||
"pid": pid,
|
||||
"session_pid": pid,
|
||||
"lock_generation": 1,
|
||||
"worktree_path": worktree_path,
|
||||
"claimant": {"username": username, "profile": profile},
|
||||
"work_lease": {
|
||||
"branch": branch,
|
||||
"issue_number": issue_number,
|
||||
"operation_type": "author_issue_work",
|
||||
"created_at": _iso(now),
|
||||
"expires_at": _iso(future),
|
||||
"last_heartbeat_at": _iso(now),
|
||||
"claimant": {"username": username, "profile": profile},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _FixtureMixin(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._tmp.name
|
||||
self.lock_dir = os.path.join(self.tmp, "locks")
|
||||
os.makedirs(self.lock_dir, mode=0o700)
|
||||
self.db_path = os.path.join(self.tmp, "control_plane.db")
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def _seed_db(self) -> control_plane_db.ControlPlaneDB:
|
||||
db = control_plane_db.ControlPlaneDB(self.db_path)
|
||||
db.upsert_session(
|
||||
session_id="prgs-author-1",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
namespace="gitea-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
db.upsert_work_item(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
kind="issue",
|
||||
number=636,
|
||||
)
|
||||
db.assign_and_lease(
|
||||
session_id="prgs-author-1",
|
||||
role="author",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
kind="issue",
|
||||
number=636,
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
class TestRedaction(unittest.TestCase):
|
||||
def test_redact_path_collapses_home(self):
|
||||
home = os.path.expanduser("~")
|
||||
self.assertEqual(
|
||||
inventory.redact_path(f"{home}/Development/Gitea-Tools"),
|
||||
"~/Development/Gitea-Tools",
|
||||
)
|
||||
|
||||
def test_redact_url_strips_userinfo_and_query(self):
|
||||
self.assertEqual(
|
||||
inventory.redact_url("https://user:[email protected]/api?token=abc"),
|
||||
"https://gitea.prgs.cc/api",
|
||||
)
|
||||
|
||||
def test_scrub_drops_credential_keys(self):
|
||||
scrubbed = inventory.scrub(
|
||||
{"token": "abc123", "api_key": "k", "profile": "prgs-author"}
|
||||
)
|
||||
self.assertEqual(scrubbed["token"], "[redacted]")
|
||||
self.assertEqual(scrubbed["api_key"], "[redacted]")
|
||||
self.assertEqual(scrubbed["profile"], "prgs-author")
|
||||
|
||||
def test_scrub_is_recursive_and_never_raises(self):
|
||||
class Weird:
|
||||
def __repr__(self) -> str:
|
||||
return "weird-obj"
|
||||
|
||||
out = inventory.scrub({"nested": [{"password": "p", "obj": Weird()}]})
|
||||
self.assertEqual(out["nested"][0]["password"], "[redacted]")
|
||||
self.assertEqual(out["nested"][0]["obj"], "weird-obj")
|
||||
|
||||
|
||||
class TestEmptyInventory(_FixtureMixin):
|
||||
def test_empty_db_and_locks_degrade_without_raising(self):
|
||||
# No DB file, no locks: sessions/leases unavailable, locks ok+empty.
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
sessions = snap.section("sessions")
|
||||
leases = snap.section("leases")
|
||||
locks = snap.section("locks")
|
||||
self.assertEqual(sessions.status, inventory.STATUS_UNAVAILABLE)
|
||||
self.assertEqual(leases.status, inventory.STATUS_UNAVAILABLE)
|
||||
self.assertEqual(locks.status, inventory.STATUS_OK)
|
||||
self.assertEqual(len(locks.items), 0)
|
||||
# Ownership authority is incomplete because the DB is missing.
|
||||
self.assertFalse(snap.ownership_authority_complete)
|
||||
self.assertEqual(snap.collisions, ())
|
||||
|
||||
def test_empty_db_present_but_unpopulated(self):
|
||||
control_plane_db.ControlPlaneDB(self.db_path) # creates schema, no rows
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
self.assertEqual(snap.section("sessions").status, inventory.STATUS_OK)
|
||||
self.assertEqual(len(snap.section("sessions").items), 0)
|
||||
self.assertEqual(snap.section("leases").status, inventory.STATUS_OK)
|
||||
self.assertTrue(snap.ownership_authority_complete)
|
||||
|
||||
|
||||
class TestPopulatedInventory(_FixtureMixin):
|
||||
def test_sections_populated_and_correlated(self):
|
||||
self._seed_db()
|
||||
wt = f"{self.tmp}/branches/issue-636-inventory-api"
|
||||
_write_lock(
|
||||
self.lock_dir,
|
||||
"prgs-Scaled-Tech-Consulting-Gitea-Tools-636.json",
|
||||
_live_lock_payload(
|
||||
issue_number=636,
|
||||
branch="feat/issue-636-inventory-api",
|
||||
worktree_path=wt,
|
||||
pid=os.getpid(),
|
||||
),
|
||||
)
|
||||
hygiene = _StubHygiene(
|
||||
entries=(
|
||||
_StubEntry(
|
||||
rel_path="branches/issue-636-inventory-api",
|
||||
branch="feat/issue-636-inventory-api",
|
||||
classification="active-issue",
|
||||
),
|
||||
)
|
||||
)
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: hygiene,
|
||||
)
|
||||
self.assertTrue(snap.ownership_authority_complete)
|
||||
self.assertEqual(len(snap.section("sessions").items), 1)
|
||||
self.assertEqual(len(snap.section("leases").items), 1)
|
||||
self.assertEqual(len(snap.section("locks").items), 1)
|
||||
self.assertEqual(len(snap.section("worktrees").items), 1)
|
||||
|
||||
# The lease, lock, and worktree for #636 correlate onto one row.
|
||||
row = next(r for r in snap.correlations if r["issue_number"] == 636)
|
||||
self.assertEqual(row["branch"], "feat/issue-636-inventory-api")
|
||||
self.assertTrue(row["lock_live"])
|
||||
self.assertEqual(row["worktree_classification"], "active-issue")
|
||||
self.assertEqual(len(row["lease_ids"]), 1)
|
||||
# No collision: live lock, live pid, matching worktree.
|
||||
self.assertEqual(snap.collisions, ())
|
||||
|
||||
def test_serialized_payload_declares_field_authority(self):
|
||||
self._seed_db()
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
payload = inventory.snapshot_to_dict(snap)
|
||||
self.assertEqual(payload["api_version"], "v1")
|
||||
self.assertEqual(payload["schema_version"], 1)
|
||||
self.assertEqual(payload["field_authority"]["sessions"], "control_plane_db")
|
||||
self.assertEqual(payload["field_authority"]["locks"], "filesystem")
|
||||
self.assertIn("sessions", payload["sections"])
|
||||
|
||||
|
||||
class TestPartialFailure(_FixtureMixin):
|
||||
def test_worktree_scan_failure_degrades_only_that_section(self):
|
||||
self._seed_db()
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("git worktree list exploded")
|
||||
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=_boom,
|
||||
)
|
||||
self.assertEqual(
|
||||
snap.section("worktrees").status, inventory.STATUS_UNAVAILABLE
|
||||
)
|
||||
self.assertIn("exploded", snap.section("worktrees").reason)
|
||||
# DB-backed sections still healthy.
|
||||
self.assertEqual(snap.section("sessions").status, inventory.STATUS_OK)
|
||||
self.assertIn("worktrees", snap.degraded_sections)
|
||||
|
||||
def test_degraded_ownership_suppresses_unowned_claim(self):
|
||||
# DB absent → sessions/leases unavailable → ownership incomplete even
|
||||
# though a lock exists and could look "unclaimed" by the DB alone.
|
||||
_write_lock(
|
||||
self.lock_dir,
|
||||
"prgs-Scaled-Tech-Consulting-Gitea-Tools-636.json",
|
||||
_live_lock_payload(
|
||||
issue_number=636,
|
||||
branch="feat/issue-636-inventory-api",
|
||||
worktree_path=f"{self.tmp}/wt",
|
||||
pid=os.getpid(),
|
||||
),
|
||||
)
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
self.assertFalse(snap.ownership_authority_complete)
|
||||
payload = inventory.snapshot_to_dict(snap)
|
||||
self.assertIn("may be treated as unowned", payload["ownership_note"])
|
||||
|
||||
|
||||
class TestCollisionDetection(_FixtureMixin):
|
||||
def test_live_lock_dead_owner_flagged(self):
|
||||
_write_lock(
|
||||
self.lock_dir,
|
||||
"prgs-Scaled-Tech-Consulting-Gitea-Tools-700.json",
|
||||
_live_lock_payload(
|
||||
issue_number=700,
|
||||
branch="feat/issue-700-x",
|
||||
worktree_path=f"{self.tmp}/wt700",
|
||||
pid=999_999_999, # not a running pid
|
||||
),
|
||||
)
|
||||
control_plane_db.ControlPlaneDB(self.db_path) # empty but present
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
kinds = {c.kind for c in snap.collisions}
|
||||
self.assertIn("live-lock-dead-owner", kinds)
|
||||
# Also lock-without-worktree, since no worktree carries the branch.
|
||||
self.assertIn("lock-without-worktree", kinds)
|
||||
|
||||
def test_duplicate_live_lock_on_same_branch(self):
|
||||
for issue in (800, 801):
|
||||
_write_lock(
|
||||
self.lock_dir,
|
||||
f"prgs-Scaled-Tech-Consulting-Gitea-Tools-{issue}.json",
|
||||
_live_lock_payload(
|
||||
issue_number=issue,
|
||||
branch="feat/issue-800-shared",
|
||||
worktree_path=f"{self.tmp}/wt{issue}",
|
||||
pid=os.getpid(),
|
||||
),
|
||||
)
|
||||
control_plane_db.ControlPlaneDB(self.db_path)
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
self.assertIn(
|
||||
"duplicate-live-lock", {c.kind for c in snap.collisions}
|
||||
)
|
||||
|
||||
def test_no_collision_when_sections_degraded(self):
|
||||
# locks ok but worktrees unavailable → lock-without-worktree must NOT
|
||||
# be asserted (a missing scan is not a missing worktree).
|
||||
_write_lock(
|
||||
self.lock_dir,
|
||||
"prgs-Scaled-Tech-Consulting-Gitea-Tools-636.json",
|
||||
_live_lock_payload(
|
||||
issue_number=636,
|
||||
branch="feat/issue-636-inventory-api",
|
||||
worktree_path=f"{self.tmp}/wt",
|
||||
pid=os.getpid(),
|
||||
),
|
||||
)
|
||||
control_plane_db.ControlPlaneDB(self.db_path)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("scan down")
|
||||
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=_boom,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"lock-without-worktree", {c.kind for c in snap.collisions}
|
||||
)
|
||||
|
||||
|
||||
class TestSectionInclude(_FixtureMixin):
|
||||
def test_include_restricts_scanned_sections(self):
|
||||
self._seed_db()
|
||||
snap = inventory.load_inventory_snapshot(
|
||||
db_path=self.db_path,
|
||||
lock_dir=self.lock_dir,
|
||||
include=("locks",),
|
||||
)
|
||||
self.assertIsNotNone(snap.section("locks"))
|
||||
self.assertIsNone(snap.section("sessions"))
|
||||
self.assertIsNone(snap.section("worktrees"))
|
||||
|
||||
|
||||
class TestRoutes(_FixtureMixin):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
# Point the loaders at the fixture DB and lock dir via env, and stub
|
||||
# the worktree scan so the route does not shell out to git.
|
||||
self._prev_env = {
|
||||
"GITEA_CONTROL_PLANE_DB": os.environ.get("GITEA_CONTROL_PLANE_DB"),
|
||||
"GITEA_ISSUE_LOCK_DIR": os.environ.get("GITEA_ISSUE_LOCK_DIR"),
|
||||
"WEBUI_TEST_OFFLINE": os.environ.get("WEBUI_TEST_OFFLINE"),
|
||||
}
|
||||
os.environ["GITEA_CONTROL_PLANE_DB"] = self.db_path
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
|
||||
os.environ["WEBUI_TEST_OFFLINE"] = "1"
|
||||
self._seed_db()
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for key, value in self._prev_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
def test_inventory_route_returns_versioned_payload(self):
|
||||
resp = self.client.get("/api/v1/inventory")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body["api_version"], "v1")
|
||||
self.assertIn("sessions", body["sections"])
|
||||
self.assertIn("field_authority", body)
|
||||
|
||||
def test_section_route_restricts_and_labels(self):
|
||||
resp = self.client.get("/api/v1/inventory/locks")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body["requested_section"], "locks")
|
||||
self.assertIn("locks", body["sections"])
|
||||
self.assertNotIn("sessions", body["sections"])
|
||||
|
||||
def test_unknown_section_is_404(self):
|
||||
resp = self.client.get("/api/v1/inventory/bogus")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
self.assertEqual(resp.json()["error"], "unknown_section")
|
||||
|
||||
def test_inventory_route_rejects_post(self):
|
||||
resp = self.client.post("/api/v1/inventory")
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
|
||||
|
||||
class TestReadOnly(_FixtureMixin):
|
||||
def test_snapshot_does_not_create_db_file(self):
|
||||
missing = os.path.join(self.tmp, "does-not-exist.db")
|
||||
inventory.load_inventory_snapshot(
|
||||
db_path=missing,
|
||||
lock_dir=self.lock_dir,
|
||||
load_hygiene=lambda: _StubHygiene(entries=()),
|
||||
)
|
||||
self.assertFalse(os.path.exists(missing))
|
||||
|
||||
def test_readonly_connection_refuses_write(self):
|
||||
self._seed_db()
|
||||
conn = inventory._open_readonly(self.db_path)
|
||||
try:
|
||||
with self.assertRaises(Exception):
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id, role, started_at, "
|
||||
"last_heartbeat_at, status) VALUES ('x','author',"
|
||||
"'t','t','active')"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── lightweight stand-ins for the #432 hygiene snapshot ──────────────────────
|
||||
|
||||
|
||||
class _StubEntry:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rel_path: str,
|
||||
branch: str | None = None,
|
||||
classification: str = "stale-clean",
|
||||
head_sha: str | None = "abc123",
|
||||
dirty_tracked: int = 0,
|
||||
dirty_untracked: bool = False,
|
||||
detached: bool = False,
|
||||
registered_worktree: bool = True,
|
||||
notes: str = "",
|
||||
) -> None:
|
||||
self.rel_path = rel_path
|
||||
self.folder_name = rel_path.split("/", 1)[-1]
|
||||
self.branch = branch
|
||||
self.classification = classification
|
||||
self.head_sha = head_sha
|
||||
self.dirty_tracked = dirty_tracked
|
||||
self.dirty_untracked = dirty_untracked
|
||||
self.detached = detached
|
||||
self.registered_worktree = registered_worktree
|
||||
self.notes = notes
|
||||
|
||||
|
||||
class _StubHygiene:
|
||||
def __init__(self, *, entries=(), scan_error=None) -> None:
|
||||
self.entries = tuple(entries)
|
||||
self.scan_error = scan_error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.lease_loader import (
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
from starlette.routing import Route
|
||||
|
||||
from webui.app import create_app
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for web UI project registry (#427)."""
|
||||
"""Tests for web UI project registry (#427) and its API evolution (#635)."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -7,61 +7,250 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.project_registry import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
REGISTRY_API_VERSION,
|
||||
SUPPORTED_SCHEMA_VERSIONS,
|
||||
RegistryError,
|
||||
default_registry_path,
|
||||
load_registry,
|
||||
onboarding_summary,
|
||||
project_to_dict,
|
||||
)
|
||||
from webui.registry_safety import is_forbidden_key
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_API_DOC = _REPO_ROOT / "docs" / "webui-project-registry-api.md"
|
||||
|
||||
|
||||
class TestProjectRegistryLoader(unittest.TestCase):
|
||||
def _valid_project(**overrides):
|
||||
project = {
|
||||
"id": "example",
|
||||
"repo_name": "Example",
|
||||
"gitea_owner": "Org",
|
||||
"remote_host": "https://gitea.example.invalid",
|
||||
"default_branch": "main",
|
||||
"local_checkout_path": ".",
|
||||
"profiles": {"author": "a", "reviewer": "r", "reconciler": "c"},
|
||||
"workflow_paths": {"skill": "skills/x.md"},
|
||||
}
|
||||
project.update(overrides)
|
||||
return project
|
||||
|
||||
|
||||
def _write_registry(payload) -> Path:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||
json.dump(payload, handle)
|
||||
return Path(handle.name)
|
||||
|
||||
|
||||
class RegistryFileCase(unittest.TestCase):
|
||||
"""Base class that cleans up temporary registry files."""
|
||||
|
||||
def setUp(self):
|
||||
self._temp_paths: list[Path] = []
|
||||
|
||||
def tearDown(self):
|
||||
for path in self._temp_paths:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def write_registry(self, payload) -> Path:
|
||||
path = _write_registry(payload)
|
||||
self._temp_paths.append(path)
|
||||
return path
|
||||
|
||||
|
||||
class TestProjectRegistryLoader(RegistryFileCase):
|
||||
def test_default_registry_loads_gitea_tools(self):
|
||||
registry = load_registry()
|
||||
self.assertEqual(registry.version, 1)
|
||||
self.assertEqual(registry.version, CURRENT_SCHEMA_VERSION)
|
||||
self.assertEqual(registry.schema_version, CURRENT_SCHEMA_VERSION)
|
||||
self.assertEqual(registry.api_version, REGISTRY_API_VERSION)
|
||||
self.assertEqual(len(registry.projects), 1)
|
||||
project = registry.projects[0]
|
||||
self.assertEqual(project.id, "gitea-tools")
|
||||
self.assertEqual(project.repo_name, "Gitea-Tools")
|
||||
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
|
||||
self.assertEqual(project.repo_full_name, "Scaled-Tech-Consulting/Gitea-Tools")
|
||||
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
|
||||
self.assertEqual(project.remote_name, "prgs")
|
||||
self.assertEqual(project.status, "active")
|
||||
self.assertEqual(project.profiles["author"], "prgs-author")
|
||||
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
|
||||
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
|
||||
self.assertIn("skill", project.workflow_paths)
|
||||
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
|
||||
|
||||
def test_registry_rejects_credential_keys(self):
|
||||
payload = {
|
||||
def test_default_registry_onboarding_summary_is_complete(self):
|
||||
summary = onboarding_summary(load_registry().projects[0])
|
||||
self.assertEqual(summary.total, summary.complete)
|
||||
self.assertEqual(summary.required_outstanding, 0)
|
||||
self.assertTrue(summary.onboarding_complete)
|
||||
|
||||
def test_version_1_registry_still_loads_with_defaults(self):
|
||||
path = self.write_registry({
|
||||
"version": 1,
|
||||
"projects": [
|
||||
{
|
||||
"id": "bad",
|
||||
"repo_name": "Bad",
|
||||
"gitea_owner": "Org",
|
||||
"remote_host": "https://gitea.example.invalid",
|
||||
"default_branch": "main",
|
||||
"local_checkout_path": ".",
|
||||
"profiles": {
|
||||
"author": "a",
|
||||
"reviewer": "r",
|
||||
"reconciler": "c",
|
||||
},
|
||||
"workflow_paths": {"skill": "skills/x.md"},
|
||||
"api_token": "secret",
|
||||
}
|
||||
_valid_project(
|
||||
onboarding_checklist=[
|
||||
{"id": "step", "title": "Step", "description": "Do it"}
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
})
|
||||
registry = load_registry(path)
|
||||
self.assertEqual(registry.schema_version, 1)
|
||||
self.assertIn(1, SUPPORTED_SCHEMA_VERSIONS)
|
||||
project = registry.projects[0]
|
||||
self.assertEqual(project.status, "active")
|
||||
self.assertIsNone(project.remote_name)
|
||||
self.assertIsNone(project.last_seen_health)
|
||||
step = project.onboarding_checklist[0]
|
||||
self.assertEqual(step.state, "pending")
|
||||
self.assertTrue(step.required)
|
||||
self.assertFalse(onboarding_summary(project).onboarding_complete)
|
||||
|
||||
def test_onboarding_summary_counts_states(self):
|
||||
path = self.write_registry({
|
||||
"version": 2,
|
||||
"projects": [
|
||||
_valid_project(
|
||||
onboarding_checklist=[
|
||||
{"id": "a", "title": "A", "description": "d", "state": "complete"},
|
||||
{"id": "b", "title": "B", "description": "d", "state": "blocked"},
|
||||
{
|
||||
"id": "c",
|
||||
"title": "C",
|
||||
"description": "d",
|
||||
"state": "pending",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
"title": "D",
|
||||
"description": "d",
|
||||
"state": "not_applicable",
|
||||
},
|
||||
]
|
||||
)
|
||||
],
|
||||
})
|
||||
summary = onboarding_summary(load_registry(path).projects[0])
|
||||
self.assertEqual(summary.total, 4)
|
||||
self.assertEqual(summary.complete, 1)
|
||||
self.assertEqual(summary.blocked, 1)
|
||||
self.assertEqual(summary.pending, 1)
|
||||
self.assertEqual(summary.not_applicable, 1)
|
||||
# Only the blocked step is both required and outstanding.
|
||||
self.assertEqual(summary.required_outstanding, 1)
|
||||
self.assertFalse(summary.onboarding_complete)
|
||||
|
||||
def test_last_seen_health_is_parsed_when_present(self):
|
||||
path = self.write_registry({
|
||||
"version": 2,
|
||||
"projects": [
|
||||
_valid_project(
|
||||
last_seen_health={
|
||||
"status": "degraded",
|
||||
"checked_at": "2026-01-01T00:00:00Z",
|
||||
"detail": "daemon restart pending",
|
||||
}
|
||||
)
|
||||
],
|
||||
})
|
||||
health = load_registry(path).projects[0].last_seen_health
|
||||
self.assertIsNotNone(health)
|
||||
self.assertEqual(health.status, "degraded")
|
||||
self.assertEqual(health.checked_at, "2026-01-01T00:00:00Z")
|
||||
|
||||
def test_registry_rejects_credential_keys(self):
|
||||
path = self.write_registry({
|
||||
"version": 1,
|
||||
"projects": [_valid_project(id="bad", api_token="redacted-placeholder")],
|
||||
})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertIn("credential", ctx.exception.remediation.lower())
|
||||
self.assertEqual(ctx.exception.field_path, "projects[0].api_token")
|
||||
|
||||
def test_unsupported_version_fails_closed_with_remediation(self):
|
||||
path = self.write_registry({"version": 99, "projects": [_valid_project()]})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertIn("unsupported registry version", ctx.exception.message)
|
||||
self.assertIn(str(CURRENT_SCHEMA_VERSION), ctx.exception.remediation)
|
||||
self.assertEqual(ctx.exception.field_path, "version")
|
||||
|
||||
def test_missing_required_field_fails_closed(self):
|
||||
broken = _valid_project()
|
||||
del broken["default_branch"]
|
||||
path = self.write_registry({"version": 2, "projects": [broken]})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertIn("default_branch", ctx.exception.message)
|
||||
self.assertEqual(ctx.exception.field_path, "projects[0]")
|
||||
|
||||
def test_unknown_status_fails_closed(self):
|
||||
path = self.write_registry({
|
||||
"version": 2,
|
||||
"projects": [_valid_project(status="mystery")],
|
||||
})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertEqual(ctx.exception.field_path, "projects[0].status")
|
||||
self.assertIn("active", ctx.exception.remediation)
|
||||
|
||||
def test_unknown_onboarding_state_fails_closed(self):
|
||||
path = self.write_registry({
|
||||
"version": 2,
|
||||
"projects": [
|
||||
_valid_project(
|
||||
onboarding_checklist=[
|
||||
{"id": "a", "title": "A", "description": "d", "state": "almost"}
|
||||
]
|
||||
)
|
||||
],
|
||||
})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertEqual(
|
||||
ctx.exception.field_path,
|
||||
"projects[0].onboarding_checklist[0].state",
|
||||
)
|
||||
|
||||
def test_missing_profile_role_fails_closed(self):
|
||||
path = self.write_registry({
|
||||
"version": 2,
|
||||
"projects": [_valid_project(profiles={"author": "a", "reviewer": "r"})],
|
||||
})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertEqual(ctx.exception.field_path, "projects[0].profiles.reconciler")
|
||||
|
||||
def test_empty_projects_fails_closed(self):
|
||||
path = self.write_registry({"version": 2, "projects": []})
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertEqual(ctx.exception.field_path, "projects")
|
||||
|
||||
def test_invalid_json_fails_closed_with_location(self):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||
json.dump(payload, handle)
|
||||
handle.write("{not json")
|
||||
path = Path(handle.name)
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
load_registry(path)
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
self._temp_paths.append(path)
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(path)
|
||||
self.assertIn("not valid JSON", ctx.exception.message)
|
||||
self.assertIn("line", ctx.exception.remediation)
|
||||
|
||||
def test_missing_file_fails_closed(self):
|
||||
missing = Path(tempfile.gettempdir()) / "webui-registry-does-not-exist.json"
|
||||
with self.assertRaises(RegistryError) as ctx:
|
||||
load_registry(missing)
|
||||
self.assertIn("could not be read", ctx.exception.message)
|
||||
|
||||
def test_default_registry_path_points_at_packaged_data(self):
|
||||
path = default_registry_path()
|
||||
@@ -81,31 +270,147 @@ class TestProjectRegistryRoutes(unittest.TestCase):
|
||||
self.assertIn("prgs-author", response.text)
|
||||
self.assertNotIn("child issue", response.text.lower())
|
||||
|
||||
def test_projects_page_shows_status_and_progress(self):
|
||||
response = self.client.get("/projects")
|
||||
self.assertIn("Status", response.text)
|
||||
self.assertIn("Onboarding", response.text)
|
||||
self.assertIn("4/4 complete", response.text)
|
||||
|
||||
def test_project_detail_renders_checklist(self):
|
||||
response = self.client.get("/projects/gitea-tools")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Onboarding checklist", response.text)
|
||||
self.assertIn("Configure execution profiles", response.text)
|
||||
self.assertIn("branches/", response.text)
|
||||
self.assertIn("Complete", response.text)
|
||||
self.assertIn("required outstanding 0", response.text)
|
||||
|
||||
def test_project_detail_404(self):
|
||||
response = self.client.get("/projects/unknown-repo")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_api_projects_json(self):
|
||||
def test_api_projects_alias_stays_compatible(self):
|
||||
response = self.client.get("/api/projects")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["version"], 1)
|
||||
# #427 consumers keep these keys.
|
||||
self.assertEqual(data["version"], CURRENT_SCHEMA_VERSION)
|
||||
self.assertIn("source_path", data)
|
||||
self.assertEqual(len(data["projects"]), 1)
|
||||
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
|
||||
self.assertIn("onboarding_checklist", data["projects"][0])
|
||||
|
||||
def test_api_v1_projects_payload(self):
|
||||
response = self.client.get("/api/v1/projects")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
|
||||
self.assertEqual(data["schema_version"], CURRENT_SCHEMA_VERSION)
|
||||
self.assertEqual(data["project_count"], 1)
|
||||
self.assertEqual(data["source"]["kind"], "file")
|
||||
self.assertTrue(data["source"]["inventory_complete"])
|
||||
project = data["projects"][0]
|
||||
self.assertEqual(project["status"], "active")
|
||||
self.assertEqual(project["remote_name"], "prgs")
|
||||
self.assertEqual(
|
||||
project["repo_full_name"], "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
)
|
||||
self.assertTrue(project["onboarding_summary"]["onboarding_complete"])
|
||||
self.assertEqual(project["onboarding_checklist"][0]["state"], "complete")
|
||||
self.assertIsNone(project["last_seen_health"])
|
||||
|
||||
def test_api_v1_project_detail(self):
|
||||
response = self.client.get("/api/v1/projects/gitea-tools")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
|
||||
self.assertEqual(data["project"]["id"], "gitea-tools")
|
||||
self.assertEqual(data["source"]["kind"], "file")
|
||||
|
||||
def test_api_v1_project_detail_missing_fails_closed(self):
|
||||
response = self.client.get("/api/v1/projects/not-registered")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
data = response.json()
|
||||
self.assertEqual(data["error"], "project_not_found")
|
||||
self.assertEqual(data["project_id"], "not-registered")
|
||||
self.assertIn("gitea-tools", data["known_project_ids"])
|
||||
self.assertIn("remediation", data)
|
||||
|
||||
def test_api_v1_projects_is_read_only(self):
|
||||
response = self.client.post("/api/v1/projects", json={})
|
||||
self.assertEqual(response.status_code, 405)
|
||||
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||
|
||||
def test_project_to_dict_is_json_safe(self):
|
||||
registry = load_registry()
|
||||
encoded = json.dumps(project_to_dict(registry.projects[0]))
|
||||
dto = project_to_dict(registry.projects[0])
|
||||
encoded = json.dumps(dto)
|
||||
self.assertIn("gitea-tools", encoded)
|
||||
# Prose may mention tokens; no serialized *key* may look like a secret.
|
||||
for key in dto:
|
||||
with self.subTest(key=key):
|
||||
self.assertFalse(is_forbidden_key(key))
|
||||
|
||||
|
||||
class TestInvalidRegistryFailsClosedOverHttp(RegistryFileCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.path = self.write_registry({"version": 42, "projects": []})
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def _with_bad_registry(self, url: str):
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ, {"WEBUI_PROJECT_REGISTRY": str(self.path)}, clear=False
|
||||
):
|
||||
return self.client.get(url)
|
||||
|
||||
def test_api_v1_reports_actionable_error(self):
|
||||
response = self._with_bad_registry("/api/v1/projects")
|
||||
self.assertEqual(response.status_code, 500)
|
||||
data = response.json()
|
||||
self.assertEqual(data["error"], "registry_invalid")
|
||||
self.assertIn("unsupported registry version", data["detail"])
|
||||
self.assertTrue(data["remediation"])
|
||||
self.assertEqual(data["field_path"], "version")
|
||||
|
||||
def test_unversioned_alias_reports_actionable_error(self):
|
||||
response = self._with_bad_registry("/api/projects")
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.json()["error"], "registry_invalid")
|
||||
|
||||
def test_html_page_reports_actionable_error(self):
|
||||
response = self._with_bad_registry("/projects")
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn("Project registry unavailable", response.text)
|
||||
self.assertIn("Remediation", response.text)
|
||||
|
||||
|
||||
class TestProjectRegistryApiDocs(unittest.TestCase):
|
||||
def test_api_contract_is_documented(self):
|
||||
self.assertTrue(_API_DOC.is_file(), f"missing {_API_DOC}")
|
||||
text = _API_DOC.read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"/api/v1/projects",
|
||||
"/api/v1/projects/{project_id}",
|
||||
"/api/projects",
|
||||
"onboarding_summary",
|
||||
"last_seen_health",
|
||||
"registry_invalid",
|
||||
"#635",
|
||||
):
|
||||
with self.subTest(token=token):
|
||||
self.assertIn(token, text)
|
||||
|
||||
def test_route_table_lists_versioned_routes(self):
|
||||
local_dev = (_REPO_ROOT / "docs" / "webui-local-dev.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("/api/v1/projects", local_dev)
|
||||
self.assertIn("webui-project-registry-api.md", local_dev)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.queue_loader import (
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Sanctioned restart / graceful reload control tests (#642).
|
||||
|
||||
Acceptance criteria under test:
|
||||
|
||||
1. The sanctioned restart path is implemented behind gates (capability,
|
||||
confirmation, operator authorization, host hook).
|
||||
2. Manual ``pkill`` stays forbidden and is classified as contamination.
|
||||
3. Post-restart mutations require clean health/session proof.
|
||||
4. Authorized restart preview, unauthorized deny, contamination classification.
|
||||
5. No entry point exposes a raw kill.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import mcp_namespace_health
|
||||
import runtime_recovery_guard
|
||||
from task_capability_map import TASK_CAPABILITY_MAP
|
||||
from webui import console_audit, console_authz, gated_actions, sanctioned_restart
|
||||
|
||||
NAMESPACE = "gitea-author"
|
||||
|
||||
# An operator-authorized, hook-configured host. Passed explicitly so no test
|
||||
# depends on (or mutates) the real process environment.
|
||||
READY_ENV = {
|
||||
sanctioned_restart.RESTART_HOOK_ENV: "launchd:cc.prgs.gitea-author",
|
||||
runtime_recovery_guard.OPERATOR_AUTHORIZATION_ENV: "ops-ticket-4821",
|
||||
}
|
||||
|
||||
|
||||
def admin(subject: str = "[email protected]") -> console_authz.Principal:
|
||||
return console_authz.Principal(
|
||||
subject=subject,
|
||||
role=console_authz.ADMIN,
|
||||
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||
authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
def viewer() -> console_authz.Principal:
|
||||
return console_authz.Principal(
|
||||
subject="[email protected]",
|
||||
role=console_authz.VIEWER,
|
||||
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||
authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
class TestCapabilityWiring(unittest.TestCase):
|
||||
"""AC1: authority is declared, not invented by the console."""
|
||||
|
||||
def test_actions_resolve_through_the_capability_map(self):
|
||||
for action_id in (
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE,
|
||||
sanctioned_restart.ACTION_RELOAD_NAMESPACE,
|
||||
):
|
||||
with self.subTest(action=action_id):
|
||||
action = console_authz.get_action(action_id)
|
||||
self.assertIsNotNone(action)
|
||||
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
|
||||
self.assertEqual(
|
||||
action.mcp_permission,
|
||||
TASK_CAPABILITY_MAP[action.task_key]["permission"],
|
||||
)
|
||||
|
||||
def test_restart_permission_is_not_a_gitea_operation(self):
|
||||
"""No configured Gitea profile should satisfy a host restart."""
|
||||
permission = TASK_CAPABILITY_MAP["restart_namespace"]["permission"]
|
||||
self.assertFalse(permission.startswith("gitea."))
|
||||
|
||||
def test_restart_is_destructive_dual_control_break_glass(self):
|
||||
action = console_authz.get_action(
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE
|
||||
)
|
||||
self.assertEqual(action.action_class, console_authz.CLASS_DESTRUCTIVE)
|
||||
self.assertEqual(action.minimum_role, console_authz.ADMIN)
|
||||
self.assertTrue(action.dual_control)
|
||||
self.assertTrue(action.break_glass)
|
||||
self.assertTrue(action.requires_confirmation)
|
||||
|
||||
def test_reload_is_privileged_but_not_destructive(self):
|
||||
action = console_authz.get_action(
|
||||
sanctioned_restart.ACTION_RELOAD_NAMESPACE
|
||||
)
|
||||
self.assertEqual(action.action_class, console_authz.CLASS_PRIVILEGED)
|
||||
self.assertTrue(action.requires_confirmation)
|
||||
|
||||
|
||||
class TestPreview(unittest.TestCase):
|
||||
"""AC4: an authorized preview renders the plan without executing it."""
|
||||
|
||||
def test_preview_lists_the_mutation_ledger(self):
|
||||
preview = sanctioned_restart.build_restart_preview(
|
||||
NAMESPACE, principal=admin(), env=READY_ENV
|
||||
)
|
||||
steps = [entry["step"] for entry in preview["mutation_ledger"]]
|
||||
self.assertEqual(
|
||||
steps, ["quiesce", "host_restart_hook", "health_recheck", "audit"]
|
||||
)
|
||||
self.assertTrue(preview["scope_valid"])
|
||||
self.assertTrue(preview["post_restart_verification_required"])
|
||||
|
||||
def test_reload_preview_drains_instead_of_restarting(self):
|
||||
preview = sanctioned_restart.build_restart_preview(
|
||||
NAMESPACE, sanctioned_restart.MODE_RELOAD,
|
||||
principal=admin(), env=READY_ENV,
|
||||
)
|
||||
steps = [entry["step"] for entry in preview["mutation_ledger"]]
|
||||
self.assertIn("host_graceful_reload", steps)
|
||||
self.assertNotIn("host_restart_hook", steps)
|
||||
|
||||
def test_preview_never_enables_execution(self):
|
||||
preview = sanctioned_restart.build_restart_preview(
|
||||
NAMESPACE, principal=admin(), env=READY_ENV
|
||||
)
|
||||
self.assertFalse(preview["execution_enabled"])
|
||||
self.assertFalse(preview["authorization"]["execution_enabled"])
|
||||
|
||||
def test_confirmation_phrase_binds_the_namespace(self):
|
||||
self.assertTrue(
|
||||
sanctioned_restart.confirmation_matches(
|
||||
NAMESPACE, sanctioned_restart.MODE_RESTART,
|
||||
"restart gitea-author",
|
||||
)
|
||||
)
|
||||
# A phrase typed for one namespace must not authorize another.
|
||||
self.assertFalse(
|
||||
sanctioned_restart.confirmation_matches(
|
||||
"gitea-merger", sanctioned_restart.MODE_RESTART,
|
||||
"restart gitea-author",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestGates(unittest.TestCase):
|
||||
"""AC1/AC4: every gate denies with a stable reason code."""
|
||||
|
||||
def _assess(self, **kwargs):
|
||||
params = {
|
||||
"principal": admin(),
|
||||
"confirmation": f"restart {NAMESPACE}",
|
||||
"env": READY_ENV,
|
||||
}
|
||||
params.update(kwargs)
|
||||
namespace = params.pop("namespace", NAMESPACE)
|
||||
mode = params.pop("mode", sanctioned_restart.MODE_RESTART)
|
||||
return sanctioned_restart.assess_restart_request(
|
||||
namespace, mode, **params
|
||||
)
|
||||
|
||||
def test_authorized_confirmed_request_passes_every_gate(self):
|
||||
result = self._assess()
|
||||
self.assertTrue(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.ALLOW_HOST_ACTION_REQUIRED
|
||||
)
|
||||
|
||||
def test_passing_every_gate_is_not_an_execution_grant(self):
|
||||
"""An allowed request still never lets the console touch the process."""
|
||||
result = self._assess()
|
||||
self.assertTrue(result["allowed"])
|
||||
self.assertFalse(result["execution_enabled"])
|
||||
self.assertFalse(result["console_executes"])
|
||||
|
||||
def test_unauthorized_principal_is_denied(self):
|
||||
result = self._assess(principal=viewer())
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_UNAUTHORIZED
|
||||
)
|
||||
|
||||
def test_anonymous_principal_is_denied(self):
|
||||
result = self._assess(principal=None)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_UNAUTHORIZED
|
||||
)
|
||||
|
||||
def test_missing_confirmation_is_denied(self):
|
||||
result = self._assess(confirmation=None)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_CONFIRMATION_MISSING
|
||||
)
|
||||
|
||||
def test_confirmation_for_another_namespace_is_denied(self):
|
||||
result = self._assess(confirmation="restart gitea-merger")
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_CONFIRMATION_MISMATCH
|
||||
)
|
||||
|
||||
def test_missing_operator_authorization_is_denied(self):
|
||||
env = {sanctioned_restart.RESTART_HOOK_ENV: "launchd:cc.prgs.author"}
|
||||
result = self._assess(env=env)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"],
|
||||
sanctioned_restart.DENY_OPERATOR_AUTHORIZATION,
|
||||
)
|
||||
|
||||
def test_missing_host_hook_is_denied_without_kill_fallback(self):
|
||||
env = {
|
||||
runtime_recovery_guard.OPERATOR_AUTHORIZATION_ENV: "ops-ticket-1",
|
||||
}
|
||||
result = self._assess(env=env)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_HOOK_NOT_CONFIGURED
|
||||
)
|
||||
|
||||
def test_fleet_scope_is_refused(self):
|
||||
for scope in ("all", "*", "fleet"):
|
||||
with self.subTest(scope=scope):
|
||||
result = self._assess(
|
||||
namespace=scope, confirmation=f"restart {scope}"
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_FLEET_SCOPE
|
||||
)
|
||||
|
||||
def test_unknown_namespace_is_refused(self):
|
||||
result = self._assess(
|
||||
namespace="gitea-nope", confirmation="restart gitea-nope"
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_UNKNOWN_NAMESPACE
|
||||
)
|
||||
|
||||
def test_unknown_mode_is_refused(self):
|
||||
result = self._assess(mode="obliterate")
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_UNKNOWN_MODE
|
||||
)
|
||||
|
||||
def test_live_contamination_marker_blocks_restart(self):
|
||||
marker = runtime_recovery_guard.build_contamination_record(
|
||||
reason_class=runtime_recovery_guard.REASON_MANUAL_DAEMON_KILL,
|
||||
command_redacted="pkill -f mcp_server.py",
|
||||
)
|
||||
result = self._assess(contamination_marker=marker)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["reason_code"], sanctioned_restart.DENY_CONTAMINATED_RUNTIME
|
||||
)
|
||||
|
||||
def test_reconciler_cleared_marker_no_longer_blocks(self):
|
||||
marker = runtime_recovery_guard.build_contamination_record(
|
||||
reason_class=runtime_recovery_guard.REASON_MANUAL_DAEMON_KILL,
|
||||
command_redacted="pkill -f mcp_server.py",
|
||||
)
|
||||
marker = dict(marker, cleared_by_reconciler=True)
|
||||
result = self._assess(contamination_marker=marker)
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
|
||||
class TestExecutionNeverKills(unittest.TestCase):
|
||||
"""AC5: no path exposes or runs a raw process kill."""
|
||||
|
||||
def test_authorized_execution_defers_to_the_host_supervisor(self):
|
||||
result = sanctioned_restart.execute_restart(
|
||||
NAMESPACE,
|
||||
principal=admin(),
|
||||
confirmation=f"restart {NAMESPACE}",
|
||||
env=READY_ENV,
|
||||
)
|
||||
self.assertTrue(result["allowed"])
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["process_kill_executed"])
|
||||
self.assertEqual(
|
||||
result["outcome"], sanctioned_restart.ALLOW_HOST_ACTION_REQUIRED
|
||||
)
|
||||
|
||||
def test_denied_execution_reports_the_refusing_gate(self):
|
||||
result = sanctioned_restart.execute_restart(
|
||||
NAMESPACE, principal=viewer(), confirmation=f"restart {NAMESPACE}",
|
||||
env=READY_ENV,
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(
|
||||
result["outcome"], sanctioned_restart.DENY_UNAUTHORIZED
|
||||
)
|
||||
self.assertFalse(result["process_kill_executed"])
|
||||
|
||||
def test_module_never_spawns_a_process(self):
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"webui", "sanctioned_restart.py",
|
||||
)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
for forbidden in (
|
||||
"import subprocess", "import signal", "os.kill", "os.system",
|
||||
"popen",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, source.lower())
|
||||
|
||||
def test_no_surface_returns_a_kill_command(self):
|
||||
payloads = [
|
||||
sanctioned_restart.build_restart_preview(
|
||||
NAMESPACE, principal=admin(), env=READY_ENV
|
||||
),
|
||||
sanctioned_restart.restart_policy(),
|
||||
sanctioned_restart.execute_restart(
|
||||
NAMESPACE, principal=admin(),
|
||||
confirmation=f"restart {NAMESPACE}", env=READY_ENV,
|
||||
),
|
||||
]
|
||||
for payload in payloads:
|
||||
rendered = json.dumps(payload, default=str).lower()
|
||||
self.assertNotIn("kill -9", rendered)
|
||||
self.assertNotIn("pkill -f", rendered)
|
||||
|
||||
def test_policy_declares_no_raw_kill_and_no_silent_restart(self):
|
||||
policy = sanctioned_restart.restart_policy()
|
||||
self.assertFalse(policy["raw_kill_exposed"])
|
||||
self.assertFalse(policy["console_executes_process_kill"])
|
||||
self.assertFalse(policy["fleet_scope_permitted"])
|
||||
self.assertFalse(policy["silent_auto_restart_permitted"])
|
||||
self.assertTrue(policy["audit_required"])
|
||||
|
||||
|
||||
class TestContaminationClassification(unittest.TestCase):
|
||||
"""AC2: manual pkill is contamination, and it blocks clean claims."""
|
||||
|
||||
def test_manual_daemon_pkill_is_contamination(self):
|
||||
result = sanctioned_restart.classify_restart_command(
|
||||
"pkill -f mcp_server.py"
|
||||
)
|
||||
self.assertTrue(result["contamination"])
|
||||
self.assertFalse(result["clean_claim_allowed"])
|
||||
self.assertIsNotNone(result["contamination_marker"])
|
||||
self.assertEqual(
|
||||
result["sanctioned_alternative"],
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE,
|
||||
)
|
||||
|
||||
def test_broad_process_kill_is_contamination(self):
|
||||
result = sanctioned_restart.classify_restart_command("killall -9 Python")
|
||||
self.assertTrue(result["contamination"])
|
||||
self.assertFalse(result["clean_claim_allowed"])
|
||||
|
||||
def test_marker_names_the_sanctioned_alternative(self):
|
||||
result = sanctioned_restart.classify_restart_command(
|
||||
"pkill -f mcp_server.py"
|
||||
)
|
||||
marker = result["contamination_marker"]
|
||||
self.assertIn(
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE, marker["detail"]
|
||||
)
|
||||
|
||||
def test_benign_command_is_not_contamination(self):
|
||||
result = sanctioned_restart.classify_restart_command("git status")
|
||||
self.assertFalse(result["contamination"])
|
||||
self.assertTrue(result["clean_claim_allowed"])
|
||||
|
||||
def test_no_command_is_not_contamination(self):
|
||||
result = sanctioned_restart.classify_restart_command(None)
|
||||
self.assertFalse(result["contamination"])
|
||||
self.assertTrue(result["clean_claim_allowed"])
|
||||
|
||||
|
||||
class TestPostRestartHealth(unittest.TestCase):
|
||||
"""AC3: a clean post-restart claim needs live client-namespace proof."""
|
||||
|
||||
def test_live_client_probe_clears_the_session(self):
|
||||
result = sanctioned_restart.verify_post_restart_health(
|
||||
NAMESPACE,
|
||||
probe_result={"success": True},
|
||||
probe_source=mcp_namespace_health.PROBE_SOURCE_CLIENT,
|
||||
registered_tools=["gitea_whoami"],
|
||||
required_tool="gitea_whoami",
|
||||
)
|
||||
self.assertEqual(result["status"], sanctioned_restart.HEALTH_CLEAN)
|
||||
self.assertTrue(result["clean_claim_allowed"])
|
||||
self.assertTrue(result["mutations_allowed"])
|
||||
|
||||
def test_offline_probe_does_not_clear_the_session(self):
|
||||
result = sanctioned_restart.verify_post_restart_health(
|
||||
NAMESPACE,
|
||||
probe_result={"success": True},
|
||||
probe_source=mcp_namespace_health.PROBE_SOURCE_OFFLINE,
|
||||
registered_tools=["gitea_whoami"],
|
||||
required_tool="gitea_whoami",
|
||||
)
|
||||
self.assertFalse(result["clean_claim_allowed"])
|
||||
self.assertFalse(result["mutations_allowed"])
|
||||
|
||||
def test_failed_probe_is_unhealthy(self):
|
||||
result = sanctioned_restart.verify_post_restart_health(
|
||||
NAMESPACE,
|
||||
probe_result={"success": False, "error": "client is closing: EOF"},
|
||||
probe_source=mcp_namespace_health.PROBE_SOURCE_CLIENT,
|
||||
registered_tools=["gitea_whoami"],
|
||||
required_tool="gitea_whoami",
|
||||
)
|
||||
self.assertEqual(result["status"], sanctioned_restart.HEALTH_UNHEALTHY)
|
||||
self.assertFalse(result["clean_claim_allowed"])
|
||||
|
||||
def test_static_registration_alone_never_clears_the_session(self):
|
||||
result = sanctioned_restart.verify_post_restart_health(
|
||||
NAMESPACE,
|
||||
registered_tools=["gitea_whoami"],
|
||||
required_tool="gitea_whoami",
|
||||
)
|
||||
self.assertFalse(result["clean_claim_allowed"])
|
||||
|
||||
|
||||
class TestAuditEmission(unittest.TestCase):
|
||||
"""Every restart attempt is audited with actor, target, and result."""
|
||||
|
||||
def _run(self, principal, sink):
|
||||
prior = os.environ.get(console_audit.AUDIT_LOG_ENV)
|
||||
os.environ[console_audit.AUDIT_LOG_ENV] = sink
|
||||
try:
|
||||
return sanctioned_restart.execute_restart(
|
||||
NAMESPACE,
|
||||
principal=principal,
|
||||
confirmation=f"restart {NAMESPACE}",
|
||||
env=READY_ENV,
|
||||
request_id="req-642",
|
||||
)
|
||||
finally:
|
||||
if prior is None:
|
||||
os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
|
||||
else:
|
||||
os.environ[console_audit.AUDIT_LOG_ENV] = prior
|
||||
|
||||
def test_allowed_attempt_is_written_with_actor_and_target(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sink = os.path.join(tmp, "audit.jsonl")
|
||||
result = self._run(admin(), sink)
|
||||
self.assertTrue(result["audit"]["written"])
|
||||
with open(sink, encoding="utf-8") as handle:
|
||||
record = json.loads(handle.read().strip())
|
||||
self.assertEqual(
|
||||
record["action"], sanctioned_restart.ACTION_RESTART_NAMESPACE
|
||||
)
|
||||
self.assertEqual(record["target"]["namespace"], NAMESPACE)
|
||||
self.assertEqual(record["target"]["mode"], "restart")
|
||||
self.assertEqual(record["result"], console_audit.RESULT_ALLOWED)
|
||||
self.assertEqual(record["actor"]["subject"], "[email protected]")
|
||||
self.assertFalse(record["metadata"]["process_kill_executed"])
|
||||
|
||||
def test_denied_attempt_is_audited_too(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sink = os.path.join(tmp, "audit.jsonl")
|
||||
self._run(viewer(), sink)
|
||||
with open(sink, encoding="utf-8") as handle:
|
||||
record = json.loads(handle.read().strip())
|
||||
self.assertEqual(record["result"], console_audit.RESULT_DENIED)
|
||||
self.assertEqual(
|
||||
record["reason_code"], sanctioned_restart.DENY_UNAUTHORIZED
|
||||
)
|
||||
|
||||
def test_restart_audit_uses_break_glass_retention(self):
|
||||
action = console_authz.get_action(
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE
|
||||
)
|
||||
self.assertEqual(
|
||||
console_audit.retention_class_for(action),
|
||||
console_audit.RETENTION_BREAK_GLASS,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistrySurface(unittest.TestCase):
|
||||
"""AC5: the console surfaces the control, still disabled, with no kill."""
|
||||
|
||||
def test_registry_exposes_both_actions_disabled(self):
|
||||
registry = gated_actions.load_action_registry()
|
||||
for action_id in (
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE,
|
||||
sanctioned_restart.ACTION_RELOAD_NAMESPACE,
|
||||
):
|
||||
with self.subTest(action=action_id):
|
||||
action = registry.get(action_id)
|
||||
self.assertIsNotNone(action)
|
||||
self.assertFalse(action.enabled)
|
||||
|
||||
def test_registry_preview_names_the_namespace_target(self):
|
||||
preview = gated_actions.preview_action(
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE, namespace=NAMESPACE
|
||||
)
|
||||
target = preview["mutation_ledger"][0]["target"]
|
||||
self.assertIn(NAMESPACE, target)
|
||||
self.assertFalse(preview["enabled"])
|
||||
|
||||
def test_registry_attempt_fails_closed(self):
|
||||
result = gated_actions.attempt_action(
|
||||
sanctioned_restart.ACTION_RESTART_NAMESPACE, namespace=NAMESPACE
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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 tests.webui_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()
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Tests for the read-only system-health API (#634).
|
||||
|
||||
Covers the acceptance criteria directly: a structured payload with readiness
|
||||
and a dependency list (AC1), version and uptime when knowable (AC2), stale
|
||||
runtime reported without a false mutation-safe claim (AC3), and the healthy /
|
||||
degraded-dependency / redaction cases (AC4).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
import control_plane_db
|
||||
from webui.app import create_app
|
||||
from webui.deployment_boundary import scan_text_for_client_secrets
|
||||
from webui.system_health import (
|
||||
API_PATH,
|
||||
STATUS_DEGRADED,
|
||||
STATUS_DOWN,
|
||||
STATUS_OK,
|
||||
STATUS_SKIPPED,
|
||||
DependencyProbe,
|
||||
StaleRuntime,
|
||||
assess_stale_runtime,
|
||||
clear_probe_cache,
|
||||
load_system_health,
|
||||
namespace_summaries,
|
||||
probe_control_plane_db,
|
||||
probe_gitea,
|
||||
process_uptime,
|
||||
redact,
|
||||
redact_url,
|
||||
snapshot_to_dict,
|
||||
)
|
||||
|
||||
|
||||
def _probe(name, status, *, required=True, detail="detail", kind="test"):
|
||||
return DependencyProbe(
|
||||
name=name,
|
||||
kind=kind,
|
||||
status=status,
|
||||
detail=detail,
|
||||
required=required,
|
||||
latency_ms=1.5,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
_ALL_HEALTHY = (
|
||||
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
_probe("gitea", STATUS_OK, required=False, kind="http"),
|
||||
)
|
||||
|
||||
_CLEAN_PARITY = StaleRuntime(
|
||||
daemon_head="abc123",
|
||||
checkout_head="abc123",
|
||||
remote_head="abc123",
|
||||
stale=False,
|
||||
determinable=True,
|
||||
mutation_safe=True,
|
||||
reasons=(),
|
||||
)
|
||||
|
||||
|
||||
class CleanParityMixin:
|
||||
"""Pin parity for tests about aggregation rather than staleness.
|
||||
|
||||
Without this the assertions depend on the real checkout: a worktree whose
|
||||
branch is ahead of its upstream is genuinely stale, which would degrade the
|
||||
overall status and make these cases fail for an unrelated reason.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
"webui.system_health.assess_stale_runtime",
|
||||
return_value=_CLEAN_PARITY,
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
|
||||
class TestDependencyAggregation(CleanParityMixin, unittest.TestCase):
|
||||
"""AC1 — readiness and dependency list derived from probe results."""
|
||||
|
||||
def test_all_healthy_is_ok_and_ready(self):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||
self.assertEqual(snapshot.status, STATUS_OK)
|
||||
self.assertTrue(snapshot.ready)
|
||||
self.assertTrue(snapshot.readiness_complete)
|
||||
self.assertEqual(snapshot.readiness_reasons, ())
|
||||
self.assertEqual(len(snapshot.dependencies), 3)
|
||||
|
||||
def test_required_dependency_down_blocks_readiness(self):
|
||||
probes = (
|
||||
_probe("control_plane_db", STATUS_DOWN, detail="file missing", kind="sqlite"),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
_probe("gitea", STATUS_OK, required=False, kind="http"),
|
||||
)
|
||||
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||
self.assertEqual(snapshot.status, STATUS_DOWN)
|
||||
self.assertFalse(snapshot.ready)
|
||||
self.assertTrue(
|
||||
any("control_plane_db" in reason for reason in snapshot.readiness_reasons)
|
||||
)
|
||||
|
||||
def test_optional_dependency_down_degrades_but_stays_ready(self):
|
||||
"""A failing optional probe must not claim the process itself is unready."""
|
||||
probes = (
|
||||
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
_probe("gitea", STATUS_DOWN, required=False, detail="timeout", kind="http"),
|
||||
)
|
||||
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||
self.assertTrue(snapshot.ready)
|
||||
self.assertTrue(any("gitea" in reason for reason in snapshot.readiness_reasons))
|
||||
|
||||
def test_unrun_required_probe_leaves_readiness_incomplete(self):
|
||||
"""Not probed is not the same as passing."""
|
||||
probes = (
|
||||
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||
_probe("repository", STATUS_SKIPPED, detail="offline", kind="git"),
|
||||
)
|
||||
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||
self.assertFalse(snapshot.ready)
|
||||
self.assertFalse(snapshot.readiness_complete)
|
||||
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||
|
||||
def test_skipped_optional_probe_does_not_block_readiness(self):
|
||||
probes = (
|
||||
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
_probe("gitea", STATUS_SKIPPED, required=False, kind="http"),
|
||||
)
|
||||
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||
self.assertTrue(snapshot.ready)
|
||||
self.assertTrue(snapshot.readiness_complete)
|
||||
|
||||
|
||||
class TestVersionAndUptime(CleanParityMixin, unittest.TestCase):
|
||||
"""AC2 — version and uptime present when knowable."""
|
||||
|
||||
def test_uptime_and_start_time_present(self):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||
self.assertGreaterEqual(snapshot.uptime_seconds, 0.0)
|
||||
self.assertIn("T", snapshot.started_at)
|
||||
|
||||
def test_process_uptime_helper_matches_shape(self):
|
||||
started_at, uptime = process_uptime()
|
||||
self.assertIn("T", started_at)
|
||||
self.assertGreaterEqual(uptime, 0.0)
|
||||
|
||||
def test_version_reports_python_and_schema_version(self):
|
||||
probes = (
|
||||
DependencyProbe(
|
||||
name="control_plane_db",
|
||||
kind="sqlite",
|
||||
status=STATUS_OK,
|
||||
detail="ok",
|
||||
required=True,
|
||||
latency_ms=1.0,
|
||||
metadata={"schema_version": control_plane_db.SCHEMA_VERSION},
|
||||
),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
)
|
||||
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||
self.assertEqual(
|
||||
snapshot.version.control_plane_schema_version,
|
||||
control_plane_db.SCHEMA_VERSION,
|
||||
)
|
||||
self.assertTrue(snapshot.version.python_version)
|
||||
|
||||
def test_version_known_flag_false_when_sha_unavailable(self):
|
||||
with mock.patch("webui.system_health._git", return_value=None):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||
self.assertIsNone(snapshot.version.git_sha)
|
||||
self.assertFalse(snapshot.version.known)
|
||||
|
||||
|
||||
class TestStaleRuntime(unittest.TestCase):
|
||||
"""AC3 — stale runtime reflected without a false mutation-safe claim."""
|
||||
|
||||
def test_matching_commits_are_mutation_safe(self):
|
||||
assessment = assess_stale_runtime(
|
||||
Path("/tmp"),
|
||||
daemon_head="aaa",
|
||||
git_reader=lambda *args: "aaa",
|
||||
)
|
||||
self.assertFalse(assessment.stale)
|
||||
self.assertTrue(assessment.determinable)
|
||||
self.assertTrue(assessment.mutation_safe)
|
||||
|
||||
def test_diverged_commits_are_stale_and_not_mutation_safe(self):
|
||||
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
|
||||
assessment = assess_stale_runtime(
|
||||
Path("/tmp"),
|
||||
daemon_head="aaa",
|
||||
git_reader=lambda *args: reads.get(args[-1]),
|
||||
)
|
||||
self.assertTrue(assessment.stale)
|
||||
self.assertFalse(assessment.mutation_safe)
|
||||
self.assertTrue(assessment.reasons)
|
||||
|
||||
def test_unknown_remote_is_not_mutation_safe(self):
|
||||
"""Indeterminate must never read as safe."""
|
||||
reads = {"HEAD": "aaa", "@{upstream}": None}
|
||||
assessment = assess_stale_runtime(
|
||||
Path("/tmp"),
|
||||
daemon_head="aaa",
|
||||
git_reader=lambda *args: reads.get(args[-1]),
|
||||
)
|
||||
self.assertFalse(assessment.determinable)
|
||||
self.assertFalse(assessment.mutation_safe)
|
||||
self.assertFalse(assessment.stale)
|
||||
self.assertTrue(
|
||||
any("indeterminate" in reason for reason in assessment.reasons)
|
||||
)
|
||||
|
||||
def test_unobservable_daemon_head_is_disclosed(self):
|
||||
assessment = assess_stale_runtime(
|
||||
Path("/tmp"),
|
||||
git_reader=lambda *args: "aaa",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("not observable" in reason for reason in assessment.reasons)
|
||||
)
|
||||
|
||||
def test_stale_runtime_degrades_overall_status(self):
|
||||
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
|
||||
# Pinned rather than inherited: this path uses the default git reader,
|
||||
# so the assertion must hold whether or not the suite runs offline.
|
||||
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": ""}), mock.patch(
|
||||
"webui.system_health._git",
|
||||
side_effect=lambda repo, *args: reads.get(args[-1]),
|
||||
):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="aaa")
|
||||
self.assertTrue(snapshot.stale_runtime.stale)
|
||||
self.assertFalse(snapshot.stale_runtime.mutation_safe)
|
||||
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||
|
||||
|
||||
class TestControlPlaneDbProbe(unittest.TestCase):
|
||||
"""The required local dependency, probed read-only."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.db_path = str(Path(self.tmp.name) / "control-plane.db")
|
||||
|
||||
def _build_db(self, schema_version):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||
conn.execute("CREATE TABLE leases (lease_id TEXT PRIMARY KEY, status TEXT)")
|
||||
conn.execute(
|
||||
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)",
|
||||
(str(schema_version),),
|
||||
)
|
||||
conn.execute("INSERT INTO leases(lease_id, status) VALUES ('l1', 'active')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def test_missing_database_is_down(self):
|
||||
probe = probe_control_plane_db(str(Path(self.tmp.name) / "absent.db"))
|
||||
self.assertEqual(probe.status, STATUS_DOWN)
|
||||
self.assertTrue(probe.required)
|
||||
self.assertIsNotNone(probe.latency_ms)
|
||||
|
||||
def test_matching_schema_is_ok(self):
|
||||
self._build_db(control_plane_db.SCHEMA_VERSION)
|
||||
probe = probe_control_plane_db(self.db_path)
|
||||
self.assertEqual(probe.status, STATUS_OK)
|
||||
self.assertEqual(
|
||||
probe.metadata["schema_version"], control_plane_db.SCHEMA_VERSION
|
||||
)
|
||||
self.assertEqual(probe.metadata["active_leases"], 1)
|
||||
|
||||
def test_mismatched_schema_is_degraded(self):
|
||||
self._build_db(control_plane_db.SCHEMA_VERSION + 99)
|
||||
probe = probe_control_plane_db(self.db_path)
|
||||
self.assertEqual(probe.status, STATUS_DEGRADED)
|
||||
|
||||
def test_probe_does_not_create_a_database(self):
|
||||
"""A health check must never initialise the substrate it inspects."""
|
||||
absent = str(Path(self.tmp.name) / "never-created.db")
|
||||
probe_control_plane_db(absent)
|
||||
self.assertFalse(Path(absent).exists())
|
||||
|
||||
def test_unreadable_database_is_down_not_raised(self):
|
||||
Path(self.db_path).write_text("this is not a sqlite database")
|
||||
probe = probe_control_plane_db(self.db_path)
|
||||
self.assertEqual(probe.status, STATUS_DOWN)
|
||||
|
||||
|
||||
class TestRedaction(unittest.TestCase):
|
||||
"""AC4 — redaction. No credential-shaped text crosses the boundary."""
|
||||
|
||||
def test_redacts_token_assignment(self):
|
||||
cleaned = redact("failed with token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")
|
||||
self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", cleaned)
|
||||
self.assertIn("[redacted]", cleaned)
|
||||
|
||||
def test_redacts_authorization_header_text(self):
|
||||
cleaned = redact("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456")
|
||||
self.assertNotIn("abcdefghijklmnopqrstuvwxyz123456", cleaned)
|
||||
|
||||
def test_redacts_long_opaque_strings(self):
|
||||
cleaned = redact("value 0123456789abcdef0123456789abcdef here")
|
||||
self.assertNotIn("0123456789abcdef0123456789abcdef", cleaned)
|
||||
|
||||
def test_url_userinfo_and_query_are_stripped(self):
|
||||
cleaned = redact_url("https://user:[email protected]/api/v1?token=xyz")
|
||||
self.assertNotIn("secretpass", cleaned)
|
||||
self.assertNotIn("token=xyz", cleaned)
|
||||
self.assertEqual(cleaned, "https://gitea.example.com/api/v1")
|
||||
|
||||
def test_url_inside_free_text_is_redacted(self):
|
||||
cleaned = redact("GET https://u:[email protected]/x?token=abc failed")
|
||||
self.assertNotIn("u:p@", cleaned)
|
||||
self.assertNotIn("token=abc", cleaned)
|
||||
|
||||
def test_gitea_probe_failure_detail_is_redacted(self):
|
||||
boom = RuntimeError(
|
||||
"connection refused for https://user:[email protected]/api/v1/version"
|
||||
)
|
||||
with mock.patch("webui.system_health.get_auth_header", return_value="token x"), \
|
||||
mock.patch("webui.system_health.api_request", side_effect=boom):
|
||||
probe = probe_gitea("gitea.example.com")
|
||||
self.assertEqual(probe.status, STATUS_DOWN)
|
||||
self.assertNotIn("hunter2", probe.detail)
|
||||
self.assertEqual(scan_text_for_client_secrets(probe.detail), [])
|
||||
|
||||
def test_credential_guard_refusal_is_a_status_not_a_crash(self):
|
||||
with mock.patch(
|
||||
"webui.system_health.get_auth_header",
|
||||
side_effect=RuntimeError("daemon guard refused"),
|
||||
):
|
||||
probe = probe_gitea("gitea.example.com")
|
||||
self.assertEqual(probe.status, STATUS_DEGRADED)
|
||||
self.assertFalse(probe.required)
|
||||
|
||||
|
||||
class TestNamespaceSummaries(unittest.TestCase):
|
||||
"""A web process cannot prove IDE namespace health, and must not claim to."""
|
||||
|
||||
def test_every_namespace_reports_unproven(self):
|
||||
rows = namespace_summaries()
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
with self.subTest(namespace=row["namespace"]):
|
||||
self.assertEqual(row["status"], "unproven")
|
||||
self.assertFalse(row["ide_namespace_proven"])
|
||||
self.assertIn("client_namespace", row["reason"])
|
||||
|
||||
|
||||
class TestSystemHealthRoutes(CleanParityMixin, unittest.TestCase):
|
||||
"""The HTTP surface: versioned path, status codes, read-only guard."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
clear_probe_cache()
|
||||
self.addCleanup(clear_probe_cache)
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def _patch_snapshot(self, probes, daemon_head="abc123"):
|
||||
snapshot = load_system_health(probes=probes, daemon_head=daemon_head)
|
||||
patcher = mock.patch(
|
||||
"webui.app.load_system_health",
|
||||
return_value=snapshot,
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return snapshot
|
||||
|
||||
def test_versioned_route_is_registered(self):
|
||||
self.assertEqual(API_PATH, "/api/v1/system/health")
|
||||
self._patch_snapshot(_ALL_HEALTHY)
|
||||
response = self.client.get(API_PATH)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_healthy_payload_shape(self):
|
||||
self._patch_snapshot(_ALL_HEALTHY)
|
||||
data = self.client.get(API_PATH).json()
|
||||
self.assertEqual(data["status"], STATUS_OK)
|
||||
self.assertTrue(data["readiness"]["ready"])
|
||||
self.assertTrue(data["readiness"]["complete"])
|
||||
self.assertEqual(data["api"], API_PATH)
|
||||
self.assertEqual(len(data["dependencies"]), 3)
|
||||
for key in ("version", "process", "stale_runtime", "mcp_namespaces"):
|
||||
self.assertIn(key, data)
|
||||
self.assertIn("uptime_seconds", data["process"])
|
||||
self.assertIn("mutation_safe", data["stale_runtime"])
|
||||
|
||||
def test_degraded_dependency_returns_503(self):
|
||||
probes = (
|
||||
_probe("control_plane_db", STATUS_DOWN, detail="missing", kind="sqlite"),
|
||||
_probe("repository", STATUS_OK, kind="git"),
|
||||
)
|
||||
self._patch_snapshot(probes)
|
||||
response = self.client.get(API_PATH)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
data = response.json()
|
||||
self.assertFalse(data["readiness"]["ready"])
|
||||
self.assertTrue(data["readiness"]["reasons"])
|
||||
|
||||
def test_dependency_entries_expose_status_and_latency(self):
|
||||
self._patch_snapshot(_ALL_HEALTHY)
|
||||
data = self.client.get(API_PATH).json()
|
||||
names = {entry["name"] for entry in data["dependencies"]}
|
||||
self.assertEqual(names, {"control_plane_db", "repository", "gitea"})
|
||||
for entry in data["dependencies"]:
|
||||
with self.subTest(dependency=entry["name"]):
|
||||
self.assertIn("status", entry)
|
||||
self.assertIn("required", entry)
|
||||
self.assertIn("latency_ms", entry)
|
||||
|
||||
def test_response_body_carries_no_client_secrets(self):
|
||||
self._patch_snapshot(_ALL_HEALTHY)
|
||||
body = self.client.get(API_PATH).text
|
||||
self.assertEqual(scan_text_for_client_secrets(body), [])
|
||||
|
||||
def test_deep_flag_is_forwarded(self):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||
with mock.patch(
|
||||
"webui.app.load_system_health", return_value=snapshot
|
||||
) as loader:
|
||||
self.client.get(f"{API_PATH}?deep=1")
|
||||
loader.assert_called_once_with(deep=True)
|
||||
|
||||
def test_shallow_is_the_default(self):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||
with mock.patch(
|
||||
"webui.app.load_system_health", return_value=snapshot
|
||||
) as loader:
|
||||
self.client.get(API_PATH)
|
||||
loader.assert_called_once_with(deep=False)
|
||||
|
||||
def test_route_rejects_mutation_methods(self):
|
||||
for method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
with self.subTest(method=method):
|
||||
response = self.client.request(method, API_PATH)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||
|
||||
def test_default_shallow_call_skips_the_network_probe(self):
|
||||
"""The expensive probe must not run unless it was asked for."""
|
||||
with mock.patch("webui.system_health.probe_gitea") as probe:
|
||||
snapshot = load_system_health(deep=False)
|
||||
probe.assert_not_called()
|
||||
gitea = next(p for p in snapshot.dependencies if p.name == "gitea")
|
||||
self.assertEqual(gitea.status, STATUS_SKIPPED)
|
||||
|
||||
|
||||
class TestHealthRouteBackwardCompatibility(unittest.TestCase):
|
||||
"""`/health` is expanded additively; MVP consumers must keep working."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_mvp_keys_are_unchanged(self):
|
||||
data = self.client.get("/health").json()
|
||||
self.assertEqual(data["status"], "ok")
|
||||
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
||||
self.assertEqual(data["mode"], "read-only-mvp")
|
||||
self.assertIn("timestamp", data)
|
||||
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
|
||||
|
||||
def test_health_points_at_the_versioned_api(self):
|
||||
data = self.client.get("/health").json()
|
||||
self.assertEqual(data["system_health_api"], API_PATH)
|
||||
self.assertIn("uptime_seconds", data)
|
||||
self.assertIn("started_at", data)
|
||||
|
||||
def test_health_runs_no_dependency_probe(self):
|
||||
"""Liveness must stay cheap: no probe, no snapshot assembly."""
|
||||
with mock.patch("webui.app.load_system_health") as loader:
|
||||
response = self.client.get("/health")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
loader.assert_not_called()
|
||||
|
||||
|
||||
class TestSnapshotSerialisation(CleanParityMixin, unittest.TestCase):
|
||||
def test_snapshot_dict_is_json_serialisable(self):
|
||||
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||
encoded = json.dumps(snapshot_to_dict(snapshot))
|
||||
self.assertIn("readiness", encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user