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:
+289
-5
@@ -18,6 +18,8 @@ after they exist as normal issues; this module never assigns incidents.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -42,9 +44,15 @@ OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
# #765: ownership could not be established for every remaining candidate.
|
||||
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.
|
||||
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_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(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
@@ -384,12 +499,20 @@ def allocate_next_work(
|
||||
lease_ttl_seconds: int | None = None,
|
||||
controller_instance_id: str | 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]:
|
||||
"""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.
|
||||
|
||||
*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.
|
||||
"""
|
||||
if db is None:
|
||||
@@ -469,6 +592,9 @@ def allocate_next_work(
|
||||
|
||||
# #765: live claims exclude work owned by a *different* controller before
|
||||
# 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:
|
||||
try:
|
||||
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
||||
@@ -484,10 +610,131 @@ def allocate_next_work(
|
||||
"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] = []
|
||||
claims_excluded: 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
|
||||
for c in ordered:
|
||||
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.
|
||||
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:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
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 {
|
||||
"success": True,
|
||||
@@ -589,6 +846,9 @@ def allocate_next_work(
|
||||
"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,
|
||||
"owner_session_id": owner_session_id,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
@@ -639,6 +899,9 @@ def allocate_next_work(
|
||||
"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,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -765,6 +1028,9 @@ def allocate_next_work(
|
||||
"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,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"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:
|
||||
"""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(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
number=number,
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
priority=int(data.get("priority") or 0),
|
||||
priority=priority,
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||
|
||||
Reference in New Issue
Block a user