Merge pull request 'fix(allocator): pre-rank exclusions and candidates_json transport (Closes #776)' (#777) from fix/issue-776-allocator-pre-rank-exclusions into master
This commit was merged in pull request #777.
This commit is contained in:
+289
-5
@@ -18,6 +18,8 @@ after they exist as normal issues; this module never assigns incidents.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -42,9 +44,15 @@ OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
|||||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||||
# #765: ownership could not be established for every remaining candidate.
|
# #765: ownership could not be established for every remaining candidate.
|
||||||
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
|
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
|
||||||
|
# #776: excluded issue still carries a live same-owner lease — resume or release.
|
||||||
|
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE = "blocked_by_excluded_own_lease"
|
||||||
|
# #776: dry-run/apply candidate-set fingerprint mismatch (CAS drift).
|
||||||
|
OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||||
|
|
||||||
# #765 skip reason code for work already claimed by a different controller.
|
# #765 skip reason code for work already claimed by a different controller.
|
||||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||||
|
# #776: controller-supplied pre-rank exclusion.
|
||||||
|
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||||
|
|
||||||
# Ownership verdicts for a live claim on a candidate (#765).
|
# Ownership verdicts for a live claim on a candidate (#765).
|
||||||
OWNERSHIP_OWN = "own"
|
OWNERSHIP_OWN = "own"
|
||||||
@@ -369,6 +377,113 @@ def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_strict_int(value: Any, *, field: str) -> int:
|
||||||
|
"""Parse an issue number; reject bools and non-integers (#776)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} must be an integer (booleans and non-integers rejected; "
|
||||||
|
f"got {type(value).__name__})"
|
||||||
|
)
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_exclude_issue_numbers(
|
||||||
|
exclude_issue_numbers: Any = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Normalize controller-supplied exclusions to a sorted unique int list (#776).
|
||||||
|
|
||||||
|
``None`` / omitted → empty list (existing behavior). Accepts a list/tuple of
|
||||||
|
integers. Rejects scalars, bools-as-ints, nested structures, and strings.
|
||||||
|
"""
|
||||||
|
if exclude_issue_numbers is None:
|
||||||
|
return []
|
||||||
|
if isinstance(exclude_issue_numbers, (str, bytes)) or not isinstance(
|
||||||
|
exclude_issue_numbers, (list, tuple)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"exclude_issue_numbers must be a list of integers "
|
||||||
|
f"(got {type(exclude_issue_numbers).__name__})"
|
||||||
|
)
|
||||||
|
out: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for idx, raw in enumerate(exclude_issue_numbers):
|
||||||
|
num = _require_strict_int(raw, field=f"exclude_issue_numbers[{idx}]")
|
||||||
|
if num not in seen:
|
||||||
|
seen.add(num)
|
||||||
|
out.append(num)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_set_fingerprint(
|
||||||
|
candidates: Sequence[WorkCandidate],
|
||||||
|
*,
|
||||||
|
exclude_issue_numbers: Sequence[int] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Stable CAS fingerprint of normalized candidate set + exclusions (#776 AC4)."""
|
||||||
|
payload = {
|
||||||
|
"candidates": sorted(
|
||||||
|
({"kind": c.kind, "number": int(c.number)} for c in candidates),
|
||||||
|
key=lambda x: (x["kind"], x["number"]),
|
||||||
|
),
|
||||||
|
"exclude_issue_numbers": list(
|
||||||
|
normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_candidates_payload(raw: Any) -> list[WorkCandidate]:
|
||||||
|
"""Decode MCP ``candidates_json`` from list or JSON string (#776 AC3).
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
* an already-decoded ``list`` of candidate dicts (native MCP transport);
|
||||||
|
* a valid JSON string that decodes to such a list (backward compatible).
|
||||||
|
|
||||||
|
Rejects malformed JSON, scalars, non-list containers, invalid records,
|
||||||
|
booleans-as-integers, and unsupported types with fail-closed ``ValueError``.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
raise ValueError("candidates_json is empty")
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
try:
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError(
|
||||||
|
f"candidates_json bytes are not valid utf-8: {exc}"
|
||||||
|
) from exc
|
||||||
|
if isinstance(raw, str):
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("candidates_json string is empty")
|
||||||
|
try:
|
||||||
|
decoded = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"malformed candidates_json JSON: {exc.msg} at pos {exc.pos}"
|
||||||
|
) from exc
|
||||||
|
raw = decoded
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(
|
||||||
|
"candidates_json must be a JSON list (or already-decoded list); "
|
||||||
|
f"got {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
candidates: list[WorkCandidate] = []
|
||||||
|
for idx, item in enumerate(raw):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid candidate record at index {idx}: expected object, "
|
||||||
|
f"got {type(item).__name__}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
candidates.append(candidate_from_dict(item))
|
||||||
|
except (KeyError, TypeError, ValueError, InvalidWorkKindError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid candidate record at index {idx}: {exc}"
|
||||||
|
) from exc
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def allocate_next_work(
|
def allocate_next_work(
|
||||||
db: ControlPlaneDB,
|
db: ControlPlaneDB,
|
||||||
*,
|
*,
|
||||||
@@ -384,12 +499,20 @@ def allocate_next_work(
|
|||||||
lease_ttl_seconds: int | None = None,
|
lease_ttl_seconds: int | None = None,
|
||||||
controller_instance_id: str | None = None,
|
controller_instance_id: str | None = None,
|
||||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||||
|
exclude_issue_numbers: Sequence[int] | None = None,
|
||||||
|
expected_candidate_set_fingerprint: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||||
|
|
||||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||||
|
|
||||||
|
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||||
|
empty preserves prior behavior.
|
||||||
|
|
||||||
|
*expected_candidate_set_fingerprint* (#776 AC4): when set on apply, rejects
|
||||||
|
material candidate-set drift vs a prior dry-run.
|
||||||
|
|
||||||
Never uses file locks or comment-only leases as the assignment source.
|
Never uses file locks or comment-only leases as the assignment source.
|
||||||
"""
|
"""
|
||||||
if db is None:
|
if db is None:
|
||||||
@@ -469,6 +592,9 @@ def allocate_next_work(
|
|||||||
|
|
||||||
# #765: live claims exclude work owned by a *different* controller before
|
# #765: live claims exclude work owned by a *different* controller before
|
||||||
# ranking, so one session's in-progress task cannot blockade the queue.
|
# ranking, so one session's in-progress task cannot blockade the queue.
|
||||||
|
# #776 AC5: load live claims in this call path immediately before selection
|
||||||
|
# (and before apply reserve) so ownership is never stale within the
|
||||||
|
# allocation attempt. Test callers may inject *claims* explicitly.
|
||||||
if claims is None:
|
if claims is None:
|
||||||
try:
|
try:
|
||||||
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
||||||
@@ -484,10 +610,131 @@ def allocate_next_work(
|
|||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"reasons": [
|
||||||
|
f"invalid exclude_issue_numbers: {exc} (fail closed, #776)"
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
exclude_set = set(exclude_nums)
|
||||||
|
cas_fp = candidate_set_fingerprint(
|
||||||
|
candidates, exclude_issue_numbers=exclude_nums
|
||||||
|
)
|
||||||
|
expected_fp = (expected_candidate_set_fingerprint or "").strip() or None
|
||||||
|
if expected_fp and expected_fp != cas_fp:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_CANDIDATE_SET_DRIFT,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"reasons": [
|
||||||
|
"candidate-set fingerprint drift: apply rejected rather than "
|
||||||
|
"silently leasing a different candidate (#776 AC4)"
|
||||||
|
],
|
||||||
|
"candidate_set_fingerprint": cas_fp,
|
||||||
|
"expected_candidate_set_fingerprint": expected_fp,
|
||||||
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
skipped: list[SkipRecord] = []
|
skipped: list[SkipRecord] = []
|
||||||
claims_excluded: list[dict[str, Any]] = []
|
claims_excluded: list[dict[str, Any]] = []
|
||||||
ownership_defects: list[dict[str, Any]] = []
|
ownership_defects: list[dict[str, Any]] = []
|
||||||
ordered = sort_candidates(list(candidates))
|
controller_excluded: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||||
|
rankable: list[WorkCandidate] = []
|
||||||
|
for c in candidates:
|
||||||
|
if int(c.number) in exclude_set:
|
||||||
|
reason = (
|
||||||
|
f"{c.kind}#{c.number} {SKIP_EXCLUDED_BY_CONTROLLER}: "
|
||||||
|
"controller pre-rank exclusion"
|
||||||
|
)
|
||||||
|
skipped.append(
|
||||||
|
SkipRecord(
|
||||||
|
c.kind,
|
||||||
|
c.number,
|
||||||
|
reason,
|
||||||
|
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
controller_excluded.append(
|
||||||
|
{
|
||||||
|
"kind": c.kind,
|
||||||
|
"number": c.number,
|
||||||
|
"reason_code": SKIP_EXCLUDED_BY_CONTROLLER,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# #776 AC5: same-owner live lease on an excluded issue is a
|
||||||
|
# structured resume/release blocker, never a silent strand.
|
||||||
|
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||||
|
ownership = classify_claim_ownership(
|
||||||
|
claim,
|
||||||
|
session_id=session_id,
|
||||||
|
controller_instance_id=controller_instance_id,
|
||||||
|
)
|
||||||
|
if ownership == OWNERSHIP_OWN and claim:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"role": role_norm,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"username": username,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": None,
|
||||||
|
"expected_role_next": None,
|
||||||
|
"reasons": [
|
||||||
|
f"{c.kind}#{c.number} is excluded_by_controller but "
|
||||||
|
"carries a live same-owner lease; resume or release "
|
||||||
|
"that lease before allocating other work (#776 AC5)"
|
||||||
|
],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"controller_instance_id": controller_instance_id,
|
||||||
|
"claims_excluded": list(claims_excluded),
|
||||||
|
"ownership_defects": list(ownership_defects),
|
||||||
|
"controller_excluded": list(controller_excluded),
|
||||||
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
|
"candidate_set_fingerprint": cas_fp,
|
||||||
|
"blocked_lease": {
|
||||||
|
"kind": c.kind,
|
||||||
|
"number": c.number,
|
||||||
|
"lease_id": claim.get("lease_id"),
|
||||||
|
"owner_session_id": claim.get("session_id"),
|
||||||
|
"owner_controller_instance_id": claim.get(
|
||||||
|
"controller_instance_id"
|
||||||
|
),
|
||||||
|
"expires_at": claim.get("expires_at"),
|
||||||
|
"safe_next_action": (
|
||||||
|
"resume the same-owner lease or release it, then "
|
||||||
|
"re-run allocation without stranding the excluded "
|
||||||
|
"issue"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
rankable.append(c)
|
||||||
|
|
||||||
|
ordered = sort_candidates(rankable)
|
||||||
selected: WorkCandidate | None = None
|
selected: WorkCandidate | None = None
|
||||||
for c in ordered:
|
for c in ordered:
|
||||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||||
@@ -560,11 +807,21 @@ def allocate_next_work(
|
|||||||
]
|
]
|
||||||
# Preserve the pre-#765 wait contract: name the blocking owner.
|
# Preserve the pre-#765 wait contract: name the blocking owner.
|
||||||
owner_session_id = claims_excluded[0].get("owner_session_id")
|
owner_session_id = claims_excluded[0].get("owner_session_id")
|
||||||
|
elif controller_excluded and not ordered:
|
||||||
|
# #776 AC7: every candidate was controller-excluded → wait / no lease.
|
||||||
|
outcome = OUTCOME_WAIT
|
||||||
|
reasons = [
|
||||||
|
f"no assignable work for role '{role_norm}': all "
|
||||||
|
f"{len(controller_excluded)} candidate(s) were removed by "
|
||||||
|
f"{SKIP_EXCLUDED_BY_CONTROLLER} before ranking; no assignment "
|
||||||
|
"or lease created (#776 AC7)"
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
outcome = OUTCOME_NO_SAFE
|
outcome = OUTCOME_NO_SAFE
|
||||||
reasons = [
|
reasons = [
|
||||||
f"no safe assignable work for role '{role_norm}' "
|
f"no safe assignable work for role '{role_norm}' "
|
||||||
f"among {len(ordered)} candidates"
|
f"among {len(ordered)} rankable candidates "
|
||||||
|
f"({len(controller_excluded)} controller-excluded)"
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -589,6 +846,9 @@ def allocate_next_work(
|
|||||||
"controller_instance_id": controller_instance_id,
|
"controller_instance_id": controller_instance_id,
|
||||||
"claims_excluded": list(claims_excluded),
|
"claims_excluded": list(claims_excluded),
|
||||||
"ownership_defects": list(ownership_defects),
|
"ownership_defects": list(ownership_defects),
|
||||||
|
"controller_excluded": list(controller_excluded),
|
||||||
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
|
"candidate_set_fingerprint": cas_fp,
|
||||||
"owner_session_id": owner_session_id,
|
"owner_session_id": owner_session_id,
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
@@ -639,6 +899,9 @@ def allocate_next_work(
|
|||||||
"controller_instance_id": controller_instance_id,
|
"controller_instance_id": controller_instance_id,
|
||||||
"claims_excluded": list(claims_excluded),
|
"claims_excluded": list(claims_excluded),
|
||||||
"ownership_defects": list(ownership_defects),
|
"ownership_defects": list(ownership_defects),
|
||||||
|
"controller_excluded": list(controller_excluded),
|
||||||
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
|
"candidate_set_fingerprint": cas_fp,
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents"
|
||||||
@@ -765,6 +1028,9 @@ def allocate_next_work(
|
|||||||
"controller_instance_id": controller_instance_id,
|
"controller_instance_id": controller_instance_id,
|
||||||
"claims_excluded": list(claims_excluded),
|
"claims_excluded": list(claims_excluded),
|
||||||
"ownership_defects": list(ownership_defects),
|
"ownership_defects": list(ownership_defects),
|
||||||
|
"controller_excluded": list(controller_excluded),
|
||||||
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
|
"candidate_set_fingerprint": cas_fp,
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents"
|
||||||
@@ -796,14 +1062,32 @@ def _next_command(role: str, c: WorkCandidate) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
"""Build a WorkCandidate from a plain dict (tests / MCP inventory).
|
||||||
|
|
||||||
|
#776: reject booleans-as-integers and non-int numbers fail-closed.
|
||||||
|
"""
|
||||||
|
if "number" not in data:
|
||||||
|
raise KeyError("number")
|
||||||
|
number = _require_strict_int(data["number"], field="number")
|
||||||
|
priority_raw = data.get("priority") or 0
|
||||||
|
if isinstance(priority_raw, bool) or not isinstance(priority_raw, (int, float)):
|
||||||
|
# Allow numeric strings only for priority? Keep strict for bools.
|
||||||
|
if isinstance(priority_raw, str) and priority_raw.strip().lstrip("-").isdigit():
|
||||||
|
priority = int(priority_raw)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"priority must be numeric (booleans rejected; "
|
||||||
|
f"got {type(priority_raw).__name__})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
priority = int(priority_raw)
|
||||||
return WorkCandidate(
|
return WorkCandidate(
|
||||||
kind=str(data.get("kind") or "issue"),
|
kind=str(data.get("kind") or "issue"),
|
||||||
number=int(data["number"]),
|
number=number,
|
||||||
state=str(data.get("state") or "open"),
|
state=str(data.get("state") or "open"),
|
||||||
labels=tuple(data.get("labels") or ()),
|
labels=tuple(data.get("labels") or ()),
|
||||||
title=str(data.get("title") or ""),
|
title=str(data.get("title") or ""),
|
||||||
priority=int(data.get("priority") or 0),
|
priority=priority,
|
||||||
head_sha=data.get("head_sha"),
|
head_sha=data.get("head_sha"),
|
||||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||||
|
|||||||
+61
-39
@@ -19202,7 +19202,9 @@ def gitea_allocate_next_work(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
include_issues: bool = True,
|
include_issues: bool = True,
|
||||||
include_prs: bool = True,
|
include_prs: bool = True,
|
||||||
candidates_json: str | None = None,
|
candidates_json: Any = None,
|
||||||
|
exclude_issue_numbers: list[int] | None = None,
|
||||||
|
expected_candidate_set_fingerprint: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Controller-owned next-work allocator using the #613 control-plane DB (#600).
|
"""Controller-owned next-work allocator using the #613 control-plane DB (#600).
|
||||||
@@ -19216,10 +19218,18 @@ def gitea_allocate_next_work(
|
|||||||
leases as the coordination source).
|
leases as the coordination source).
|
||||||
|
|
||||||
Outcomes include: ``assigned_work``, ``preview``, ``wait``,
|
Outcomes include: ``assigned_work``, ``preview``, ``wait``,
|
||||||
``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``.
|
``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``,
|
||||||
|
``blocked_by_excluded_own_lease``, ``candidate_set_drift``.
|
||||||
|
|
||||||
*candidates_json* may inject a JSON list of candidate dicts (tests /
|
*candidates_json* may inject a candidate list as an already-decoded list
|
||||||
controller overrides). When omitted, open issues/PRs are loaded from Gitea.
|
(native MCP transport) or a JSON string (backward compatible). When
|
||||||
|
omitted, open issues/PRs are loaded from Gitea.
|
||||||
|
|
||||||
|
*exclude_issue_numbers* (#776): typed list of issue/PR numbers removed
|
||||||
|
before ranking. Omitted/empty retains prior behavior.
|
||||||
|
|
||||||
|
*expected_candidate_set_fingerprint* (#776 AC4): optional CAS pin from a
|
||||||
|
prior dry-run; apply fails closed on material candidate-set drift.
|
||||||
|
|
||||||
#612 remains downstream: raw monitoring incidents are never candidates.
|
#612 remains downstream: raw monitoring incidents are never candidates.
|
||||||
"""
|
"""
|
||||||
@@ -19273,15 +19283,12 @@ def gitea_allocate_next_work(
|
|||||||
|
|
||||||
inv_reasons: list[str] = []
|
inv_reasons: list[str] = []
|
||||||
candidates: list[Any] = []
|
candidates: list[Any] = []
|
||||||
if candidates_json:
|
if candidates_json is not None and candidates_json != "":
|
||||||
try:
|
try:
|
||||||
raw = json.loads(candidates_json)
|
# #776 AC3: accept decoded list or JSON string; reject malformed.
|
||||||
if not isinstance(raw, list):
|
candidates = allocator_service.normalize_candidates_payload(
|
||||||
raise ValueError("candidates_json must be a JSON list")
|
candidates_json
|
||||||
for item in raw:
|
)
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
candidates.append(allocator_service.candidate_from_dict(item))
|
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -19324,24 +19331,40 @@ def gitea_allocate_next_work(
|
|||||||
sid = (session_id or "").strip() or (
|
sid = (session_id or "").strip() or (
|
||||||
f"{profile_name or 'session'}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
f"{profile_name or 'session'}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||||
)
|
)
|
||||||
result = allocator_service.allocate_next_work(
|
try:
|
||||||
db,
|
result = allocator_service.allocate_next_work(
|
||||||
session_id=sid,
|
db,
|
||||||
role=role_in,
|
session_id=sid,
|
||||||
remote=remote if remote in REMOTES else remote,
|
role=role_in,
|
||||||
org=o,
|
remote=remote if remote in REMOTES else remote,
|
||||||
repo=r,
|
org=o,
|
||||||
candidates=candidates,
|
repo=r,
|
||||||
apply=bool(apply),
|
candidates=candidates,
|
||||||
profile_name=profile_name,
|
apply=bool(apply),
|
||||||
username=username,
|
profile_name=profile_name,
|
||||||
controller_instance_id=allocator_service.resolve_controller_instance_id(),
|
username=username,
|
||||||
)
|
controller_instance_id=allocator_service.resolve_controller_instance_id(),
|
||||||
|
exclude_issue_numbers=exclude_issue_numbers,
|
||||||
|
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": allocator_service.OUTCOME_NO_SAFE,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"reasons": [
|
||||||
|
f"invalid allocator input: {_redact(str(exc))} (fail closed, #776)"
|
||||||
|
],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
if inv_reasons:
|
if inv_reasons:
|
||||||
result.setdefault("inventory_warnings", inv_reasons)
|
result.setdefault("inventory_warnings", inv_reasons)
|
||||||
result["candidate_count"] = len(candidates)
|
result["candidate_count"] = len(candidates)
|
||||||
result["inventory_source"] = (
|
result["inventory_source"] = (
|
||||||
"candidates_json" if candidates_json else "gitea_live"
|
"candidates_json"
|
||||||
|
if candidates_json is not None and candidates_json != ""
|
||||||
|
else "gitea_live"
|
||||||
)
|
)
|
||||||
result["inventory_complete"] = True
|
result["inventory_complete"] = True
|
||||||
result["selection_policy"] = allocator_service.SELECTION_POLICY
|
result["selection_policy"] = allocator_service.SELECTION_POLICY
|
||||||
@@ -19435,7 +19458,7 @@ def gitea_workflow_dashboard(
|
|||||||
include_issues: bool = True,
|
include_issues: bool = True,
|
||||||
include_prs: bool = True,
|
include_prs: bool = True,
|
||||||
include_non_active_leases: bool = True,
|
include_non_active_leases: bool = True,
|
||||||
candidates_json: str | None = None,
|
candidates_json: Any = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Read-only MCP workflow dashboard: queues, leases, blockers, next safe action (#605).
|
"""Read-only MCP workflow dashboard: queues, leases, blockers, next safe action (#605).
|
||||||
@@ -19444,9 +19467,10 @@ def gitea_workflow_dashboard(
|
|||||||
still requires ``gitea_allocate_next_work``. Never presents blocked or
|
still requires ``gitea_allocate_next_work``. Never presents blocked or
|
||||||
terminal-locked items as safe next work.
|
terminal-locked items as safe next work.
|
||||||
|
|
||||||
*candidates_json* may inject a JSON list of candidate dicts (tests /
|
*candidates_json* may inject a candidate list as an already-decoded list
|
||||||
offline fixtures). When omitted, open issues/PRs are loaded from Gitea.
|
or JSON string (#776 transport parity). When omitted, open issues/PRs are
|
||||||
Incomplete live inventory fails closed (no safe suggestions).
|
loaded from Gitea. Incomplete live inventory fails closed (no safe
|
||||||
|
suggestions).
|
||||||
"""
|
"""
|
||||||
read_block = _profile_operation_gate("gitea.read")
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
if read_block:
|
if read_block:
|
||||||
@@ -19471,15 +19495,11 @@ def gitea_workflow_dashboard(
|
|||||||
inv_reasons: list[str] = []
|
inv_reasons: list[str] = []
|
||||||
candidates: list[Any] = []
|
candidates: list[Any] = []
|
||||||
inventory_complete = True
|
inventory_complete = True
|
||||||
if candidates_json:
|
if candidates_json is not None and candidates_json != "":
|
||||||
try:
|
try:
|
||||||
raw = json.loads(candidates_json)
|
candidates = allocator_service.normalize_candidates_payload(
|
||||||
if not isinstance(raw, list):
|
candidates_json
|
||||||
raise ValueError("candidates_json must be a JSON list")
|
)
|
||||||
for item in raw:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
candidates.append(allocator_service.candidate_from_dict(item))
|
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -19570,7 +19590,9 @@ def gitea_workflow_dashboard(
|
|||||||
)
|
)
|
||||||
payload = snap.as_dict()
|
payload = snap.as_dict()
|
||||||
payload["inventory_source"] = (
|
payload["inventory_source"] = (
|
||||||
"candidates_json" if candidates_json else "gitea_live"
|
"candidates_json"
|
||||||
|
if candidates_json is not None and candidates_json != ""
|
||||||
|
else "gitea_live"
|
||||||
)
|
)
|
||||||
payload["limit_applies_to"] = "lease_listing_only"
|
payload["limit_applies_to"] = "lease_listing_only"
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Allocator pre-rank exclusions and candidates_json transport (#776).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
|
||||||
|
* excluded top candidate selects the next safe candidate;
|
||||||
|
* all candidates excluded → WAIT, no lease;
|
||||||
|
* decoded-list and JSON-string candidates_json;
|
||||||
|
* malformed / type-invalid fail-closed cases;
|
||||||
|
* dry-run/apply fingerprint match and drift rejection;
|
||||||
|
* foreign lease and same-owner lease on excluded issue;
|
||||||
|
* skipped-accounting reason parity (excluded_by_controller);
|
||||||
|
* public MCP entry-point coverage for exclude_issue_numbers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
from allocator_service import (
|
||||||
|
OUTCOME_ASSIGNED,
|
||||||
|
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||||
|
OUTCOME_CANDIDATE_SET_DRIFT,
|
||||||
|
OUTCOME_NO_SAFE,
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
OUTCOME_WAIT,
|
||||||
|
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||||
|
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||||
|
WorkCandidate,
|
||||||
|
allocate_next_work,
|
||||||
|
candidate_from_dict,
|
||||||
|
candidate_set_fingerprint,
|
||||||
|
normalize_candidates_payload,
|
||||||
|
normalize_exclude_issue_numbers,
|
||||||
|
)
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
|
||||||
|
REMOTE = "prgs"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
MINE = "ctl-mine-776"
|
||||||
|
THEIRS = "ctl-theirs-776"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(number: int, **kwargs) -> WorkCandidate:
|
||||||
|
base = dict(
|
||||||
|
kind="issue",
|
||||||
|
number=number,
|
||||||
|
state="open",
|
||||||
|
labels=("status:ready", "type:bug"),
|
||||||
|
title=f"issue {number}",
|
||||||
|
priority=20,
|
||||||
|
)
|
||||||
|
base.update(kwargs)
|
||||||
|
return WorkCandidate(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
|
||||||
|
return {
|
||||||
|
"lease_id": f"lease-{number}",
|
||||||
|
"session_id": session_id,
|
||||||
|
"controller_instance_id": instance,
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"expires_at": "2026-07-21T12:00:00Z",
|
||||||
|
"work_kind": kind,
|
||||||
|
"work_number": number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cand_dict(number: int, **kwargs) -> dict:
|
||||||
|
d = {
|
||||||
|
"kind": "issue",
|
||||||
|
"number": number,
|
||||||
|
"state": "open",
|
||||||
|
"labels": ["status:ready", "type:bug"],
|
||||||
|
"title": f"issue {number}",
|
||||||
|
"priority": 20,
|
||||||
|
}
|
||||||
|
d.update(kwargs)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class AllocatorExcludeServiceTest(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-776",
|
||||||
|
role="author",
|
||||||
|
remote=REMOTE,
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
profile_name="prgs-author",
|
||||||
|
controller_instance_id=MINE,
|
||||||
|
claims={},
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||||
|
|
||||||
|
def test_exclude_617_before_ranking_never_selects(self) -> None:
|
||||||
|
"""AC2/AC8: highest-ranked #617 is removed before ranking."""
|
||||||
|
cands = [_issue(617), _issue(700), _issue(701)]
|
||||||
|
res = self._alloc(cands, exclude_issue_numbers=[617])
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(res["selected"]["number"], 700)
|
||||||
|
skipped = {s["number"]: s for s in res["skipped"]}
|
||||||
|
self.assertIn(617, skipped)
|
||||||
|
self.assertEqual(
|
||||||
|
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||||
|
)
|
||||||
|
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])
|
||||||
|
|
||||||
|
def test_excluded_top_selects_next_safe(self) -> None:
|
||||||
|
"""AC2: excluding the oldest ready issue promotes the next number."""
|
||||||
|
cands = [_issue(600), _issue(601), _issue(602)]
|
||||||
|
res = self._alloc(cands, exclude_issue_numbers=[600])
|
||||||
|
self.assertEqual(res["selected"]["number"], 601)
|
||||||
|
|
||||||
|
def test_all_candidates_excluded_wait_no_lease(self) -> None:
|
||||||
|
"""AC7: every candidate excluded → WAIT, no assignment."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||||
|
self.assertIsNone(res["selected"])
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
self.assertEqual(len(res["controller_excluded"]), 2)
|
||||||
|
|
||||||
|
def test_omit_exclude_retains_existing_behavior(self) -> None:
|
||||||
|
"""AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
res = self._alloc(cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 617)
|
||||||
|
self.assertEqual(res.get("exclude_issue_numbers"), [])
|
||||||
|
|
||||||
|
def test_foreign_lease_still_skipped(self) -> None:
|
||||||
|
"""AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
claims = {
|
||||||
|
("issue", 700): _claim(700, session_id="other", instance=THEIRS),
|
||||||
|
}
|
||||||
|
res = self._alloc(
|
||||||
|
cands, exclude_issue_numbers=[617], claims=claims
|
||||||
|
)
|
||||||
|
# 617 excluded, 700 foreign → wait, no selection
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||||
|
self.assertIsNone(res["selected"])
|
||||||
|
codes = {s["reason_code"] for s in res["skipped"]}
|
||||||
|
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
|
||||||
|
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)
|
||||||
|
|
||||||
|
def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
|
||||||
|
"""AC5: excluded + live same-owner lease → structured blocker."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
claims = {
|
||||||
|
("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
|
||||||
|
}
|
||||||
|
res = self._alloc(
|
||||||
|
cands, exclude_issue_numbers=[617], claims=claims, apply=True
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
self.assertEqual(res["blocked_lease"]["number"], 617)
|
||||||
|
self.assertIn("resume", res["blocked_lease"]["safe_next_action"])
|
||||||
|
|
||||||
|
def test_dry_run_apply_fingerprint_match(self) -> None:
|
||||||
|
"""AC4: dry-run and apply share the same fingerprint."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
|
||||||
|
apply_res = self._alloc(
|
||||||
|
cands,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=True,
|
||||||
|
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
dry["candidate_set_fingerprint"],
|
||||||
|
apply_res["candidate_set_fingerprint"],
|
||||||
|
)
|
||||||
|
self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||||
|
|
||||||
|
def test_apply_rejects_fingerprint_drift(self) -> None:
|
||||||
|
"""AC4: material candidate-set drift fails closed on apply."""
|
||||||
|
cands = [_issue(617), _issue(700)]
|
||||||
|
res = self._alloc(
|
||||||
|
cands,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=True,
|
||||||
|
expected_candidate_set_fingerprint="0" * 64,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
|
||||||
|
def test_fingerprint_stable_helper(self) -> None:
|
||||||
|
cands = [_issue(700), _issue(617)]
|
||||||
|
a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
|
||||||
|
b = candidate_set_fingerprint(
|
||||||
|
list(reversed(cands)), exclude_issue_numbers=[617]
|
||||||
|
)
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
def test_normalize_exclude_rejects_bool(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_exclude_issue_numbers([True])
|
||||||
|
|
||||||
|
def test_normalize_exclude_rejects_scalar(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_exclude_issue_numbers(617)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidatesJsonNormalizeTest(unittest.TestCase):
|
||||||
|
def test_decoded_list(self) -> None:
|
||||||
|
"""AC3: already-decoded list from MCP transport."""
|
||||||
|
cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
|
||||||
|
self.assertEqual([c.number for c in cands], [617, 700])
|
||||||
|
|
||||||
|
def test_json_string(self) -> None:
|
||||||
|
"""AC3: backward-compatible JSON string."""
|
||||||
|
raw = json.dumps([_cand_dict(617)])
|
||||||
|
cands = normalize_candidates_payload(raw)
|
||||||
|
self.assertEqual(cands[0].number, 617)
|
||||||
|
|
||||||
|
def test_malformed_json_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
normalize_candidates_payload("{not json")
|
||||||
|
self.assertIn("malformed", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_scalar_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_candidates_payload(42)
|
||||||
|
|
||||||
|
def test_bool_number_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_candidates_payload([_cand_dict(True)]) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_invalid_record_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_candidates_payload(["not-a-dict"])
|
||||||
|
|
||||||
|
def test_object_not_list_fail_closed(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
normalize_candidates_payload(json.dumps({"number": 1}))
|
||||||
|
|
||||||
|
def test_candidate_from_dict_rejects_bool_number(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
candidate_from_dict({"kind": "issue", "number": True})
|
||||||
|
|
||||||
|
|
||||||
|
class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
|
||||||
|
"""Public MCP entry-point coverage (#776 AC8)."""
|
||||||
|
|
||||||
|
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 _call(self, **kwargs):
|
||||||
|
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
|
||||||
|
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.get_profile",
|
||||||
|
return_value={"profile_name": "prgs-author", "role": "author"},
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||||
|
):
|
||||||
|
return srv.gitea_allocate_next_work(
|
||||||
|
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
|
||||||
|
"""AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
|
||||||
|
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||||
|
res = self._call(
|
||||||
|
candidates_json=candidates,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertEqual(res["selected"]["number"], 700)
|
||||||
|
skipped = {s["number"]: s for s in res["skipped"]}
|
||||||
|
self.assertEqual(
|
||||||
|
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["selected"]["number"], 617)
|
||||||
|
|
||||||
|
def test_mcp_exclude_617_json_string_apply(self) -> None:
|
||||||
|
"""AC8: JSON-string transport + apply never leases #617."""
|
||||||
|
raw = json.dumps([_cand_dict(617), _cand_dict(700)])
|
||||||
|
res = self._call(
|
||||||
|
candidates_json=raw,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
self.assertEqual(res["assignment"]["work_number"], 700)
|
||||||
|
self.assertNotEqual(res["selected"]["number"], 617)
|
||||||
|
|
||||||
|
def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
|
||||||
|
res = self._call(candidates_json="{bad", apply=False)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_mcp_bool_number_fail_closed(self) -> None:
|
||||||
|
res = self._call(
|
||||||
|
candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
|
||||||
|
def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
|
||||||
|
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||||
|
dry = self._call(
|
||||||
|
candidates_json=candidates,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
apply_res = self._call(
|
||||||
|
candidates_json=candidates,
|
||||||
|
exclude_issue_numbers=[617],
|
||||||
|
apply=True,
|
||||||
|
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
dry["candidate_set_fingerprint"],
|
||||||
|
apply_res["candidate_set_fingerprint"],
|
||||||
|
)
|
||||||
|
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user