fix(allocator): pre-rank exclusions and candidates_json transport (#776)

Expose exclude_issue_numbers on gitea_allocate_next_work, remove excluded
numbers before ranking, normalize decoded-list and JSON-string
candidates_json fail-closed, and return candidate-set fingerprints for
dry-run/apply CAS. Same-owner leases on excluded issues surface a
structured resume/release blocker.

Closes #776

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-20 22:40:39 -04:00
co-authored by Claude Opus 4.8
parent 52ded0ea71
commit d17f055e86
3 changed files with 699 additions and 44 deletions
+61 -39
View File
@@ -19202,7 +19202,9 @@ def gitea_allocate_next_work(
repo: str | None = None,
include_issues: 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,
) -> dict:
"""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).
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 /
controller overrides). When omitted, open issues/PRs are loaded from Gitea.
*candidates_json* may inject a candidate list as an already-decoded list
(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.
"""
@@ -19273,15 +19283,12 @@ def gitea_allocate_next_work(
inv_reasons: list[str] = []
candidates: list[Any] = []
if candidates_json:
if candidates_json is not None and candidates_json != "":
try:
raw = json.loads(candidates_json)
if not isinstance(raw, list):
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))
# #776 AC3: accept decoded list or JSON string; reject malformed.
candidates = allocator_service.normalize_candidates_payload(
candidates_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
@@ -19324,24 +19331,40 @@ def gitea_allocate_next_work(
sid = (session_id or "").strip() or (
f"{profile_name or 'session'}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
)
result = allocator_service.allocate_next_work(
db,
session_id=sid,
role=role_in,
remote=remote if remote in REMOTES else remote,
org=o,
repo=r,
candidates=candidates,
apply=bool(apply),
profile_name=profile_name,
username=username,
controller_instance_id=allocator_service.resolve_controller_instance_id(),
)
try:
result = allocator_service.allocate_next_work(
db,
session_id=sid,
role=role_in,
remote=remote if remote in REMOTES else remote,
org=o,
repo=r,
candidates=candidates,
apply=bool(apply),
profile_name=profile_name,
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:
result.setdefault("inventory_warnings", inv_reasons)
result["candidate_count"] = len(candidates)
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["selection_policy"] = allocator_service.SELECTION_POLICY
@@ -19435,7 +19458,7 @@ def gitea_workflow_dashboard(
include_issues: bool = True,
include_prs: bool = True,
include_non_active_leases: bool = True,
candidates_json: str | None = None,
candidates_json: Any = None,
limit: int = 100,
) -> dict:
"""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
terminal-locked items as safe next work.
*candidates_json* may inject a JSON list of candidate dicts (tests /
offline fixtures). When omitted, open issues/PRs are loaded from Gitea.
Incomplete live inventory fails closed (no safe suggestions).
*candidates_json* may inject a candidate list as an already-decoded list
or JSON string (#776 transport parity). When omitted, open issues/PRs are
loaded from Gitea. Incomplete live inventory fails closed (no safe
suggestions).
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
@@ -19471,15 +19495,11 @@ def gitea_workflow_dashboard(
inv_reasons: list[str] = []
candidates: list[Any] = []
inventory_complete = True
if candidates_json:
if candidates_json is not None and candidates_json != "":
try:
raw = json.loads(candidates_json)
if not isinstance(raw, list):
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))
candidates = allocator_service.normalize_candidates_payload(
candidates_json
)
except Exception as exc: # noqa: BLE001
return {
"success": False,
@@ -19570,7 +19590,9 @@ def gitea_workflow_dashboard(
)
payload = snap.as_dict()
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"
return payload