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:
2026-07-19 14:12:04 -04:00
parent 854818e65a
commit ad59053cd7
5 changed files with 764 additions and 25 deletions
+26 -1
View File
@@ -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)),