fix: rank complete allocator inventory and resolve declared dependencies (Closes #758)
The author allocator could select an ineligible issue for two independent reasons, both fixed here. Defect 1 — candidate truncation before ranking. The loader fetched the complete open inventory via api_get_all, then sliced it to `limit` items before constructing any WorkCandidate. Because every status:ready issue ties at priority 20 and the tie breaks on lowest number, the slice — not the ranking — decided the winner, so the same query returned different answers at different `limit` values. Candidate construction now consumes the full listing; `limit` bounds the reported skip list only, and any such truncation is reported explicitly rather than silently. Defect 2 — dependency state inferred from body substrings. Only "blocked on #" and "downstream of #" were recognized, so the repository's canonical `Depends: #N, #N` field never set dependency_unmet and dependency-blocked issues were emitted as eligible. The new allocator_dependencies module parses the canonical declaration into structured references and resolves each against live issue state: open means unmet, closed means met, and unavailable evidence fails closed. The complete open listing proves openness without extra calls; anything absent from it is confirmed by a cached targeted lookup instead of being assumed closed. The legacy "blocked on #" marker still parses. A failed listing now marks the inventory incomplete and the tool fails closed instead of ranking a partial set. Selection already advanced past a skipped candidate; with dependencies resolved correctly that fall-through now actually engages, and is covered by a regression. Tests: 35 new cases across parser, resolver, loader, and an MCP-level gitea_allocate_next_work regression, including limit-invariant selection over a 73-candidate inventory and a guard proving pre-ranking truncation would change the winner. No issue number is special-cased.
This commit is contained in:
+26
-1
@@ -41,6 +41,15 @@ OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
|
||||
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
|
||||
# alongside allocator results so the flat status:ready tier and its
|
||||
# oldest-number tie-break are explicit rather than incidental.
|
||||
SELECTION_POLICY = (
|
||||
"rank complete inventory by (priority desc, PRs before issues, "
|
||||
"number asc); status:ready issues share priority 20, so the oldest "
|
||||
"eligible number wins ties; result limits never affect selection"
|
||||
)
|
||||
|
||||
ROLE_AUTHOR = "author"
|
||||
ROLE_REVIEWER = "reviewer"
|
||||
ROLE_MERGER = "merger"
|
||||
@@ -242,7 +251,23 @@ def classify_skip(
|
||||
|
||||
|
||||
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||
"""Higher priority first; then lower number (older issues) for stability."""
|
||||
"""Rank candidates deterministically (#758 AC10).
|
||||
|
||||
Ordering key, in precedence order:
|
||||
|
||||
1. ``priority`` descending — the loader scores ``status:ready`` issues at
|
||||
20 and everything else at 1, so the ready queue ties at a single value
|
||||
by design;
|
||||
2. PRs before issues — in-flight review work drains before new authoring;
|
||||
3. ``number`` ascending — oldest first, which is what actually breaks the
|
||||
flat ``status:ready`` tie.
|
||||
|
||||
Because the ready tier is intentionally flat, rule 3 decides most real
|
||||
selections. That is only safe when ranking sees the *complete* candidate
|
||||
inventory: truncating before this call silently redefines "oldest" as
|
||||
"oldest among whatever survived the slice", which is the defect #758
|
||||
fixed. Callers must rank everything and bound reporting afterwards.
|
||||
"""
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||
|
||||
Reference in New Issue
Block a user