claude-agent-service/app/afk/dispatch_policy.py
Viktor Barzin e34640cc47
Some checks failed
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
Build and Push / deploy (push) Has been cancelled
Build and Push / notify-failure (push) Has been cancelled
afk: wire the T3 adapter to the REAL orchestration contract + fix priority
The T3 dispatch adapter was written against a guessed wire shape that the test
fake accepted but the live t3-afk server 400s — so the previously-green suite did
NOT mean the loop was actually wired to T3. Reverse-engineered the real contract
from the v0.0.27 binary, verified it live against t3-afk (including multi-turn),
and rewrote the adapter to match:

- dispatch sends BARE commands keyed by `type` (not a `command` string), with
  client-minted threadId/commandId/messageId + createdAt; the server replies
  {sequence}, so dispatch returns the id it generated (never one parsed back).
- a thread lives in a project (workspaceRoot = the repo checkout the agent runs
  in), so dispatch ensures the repo's project (snapshot -> project.create iff
  absent) before thread.create + thread.turn.start.
- add send_turn() for follow-up turns on an existing thread — multi-turn context
  retention is verified live (turn 2 recalled turn 1).
- watcher reads thread liveness from latestTurn.state (completed->idle,
  running/in_progress/pending->running, errored->error), not a non-existent
  top-level `status` field.

Guard against recurrence: the test fake now REJECTS any command lacking a `type`
discriminator (the original bug fails loudly), plus an opt-in live smoke test
(tests/test_afk_t3_live.py) so "green" can mean "wired to T3".

Also align dispatch_policy to lower-priority-value-first (P0 before P1), matching
tracker conventions and Issue.priority's own docstring — it had deliberately
diverged to higher-first. Loop still ships DISABLED (kill switch on, empty
allowlist). 416 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:27:00 +00:00

118 lines
5.4 KiB
Python

"""Dispatch policy — the PURE gate deciding which ready issues to run *now*.
``select_dispatchable`` is the loop's first decision each tick: given every
issue the tracker reported ready, the loop config, and the set of repos that
already have an agent in flight, it returns the ordered list of issues to
dispatch this round. It does **no IO** — no tracker calls, no T3, no clock — so
it is exhaustively unit-testable and the loop stays a thin shell around it.
What it encapsulates (the dispatch predicate from the AFK pipeline design doc):
* **Kill switch** — ``config.kill_switch`` short-circuits to ``[]`` before any
per-issue work. The whole loop ships disabled; this is the master off.
* **Trust gate** — only ``issue.labeled_by_trusted`` issues are eligible. On a
private repo the gating label *is* the authorization, so an issue made ready
by an untrusted/bot actor must never auto-run (prompt-injection defense).
* **Allowlist** — ``issue.repo`` must be in ``config.allowlist``. An empty
allowlist dispatches nothing even with the kill switch off (the deliberate
two-gate posture: arming the loop takes both).
* **Per-repo lock** — any repo already in ``in_flight_repos`` is skipped; at
most one agent runs per repo (two would collide on the working tree).
* **blocked_by gating** — ``issue.blocked_by`` lists the issue numbers of
blockers that are still OPEN, so a non-empty list means "still blocked" and
the issue is skipped.
* **One-agent-per-repo within the batch** — because a repo hosts only one
in-flight agent, a single call returns at most ONE decision per repo: the
most-urgent eligible issue in that repo wins the slot. (A more-urgent issue
that is itself ineligible does not consume the slot — the best *eligible*
candidate does.)
* **Priority ordering** — the surviving per-repo winners are returned
lowest-``priority``-value-first (P0 before P1 before P2), with a deterministic
tiebreaker (ascending issue number) so the output is a total, stable order
independent of input order.
PRIORITY DIRECTION — lower ``Issue.priority`` runs first, matching tracker
conventions (P0/P1 are more urgent than P2) and ``Issue.priority``'s own
docstring in ``types``. The ordering lives here (the one place that consumes
``priority`` for dispatch), so this module is the source of truth for the
direction.
Pure: it never mutates its inputs — the caller's issue list, the config, and the
``in_flight_repos`` set are all left exactly as passed.
"""
from .types import Config, DispatchDecision, Issue
def select_dispatchable(
issues: list[Issue],
config: Config,
in_flight_repos: set[str],
) -> list[DispatchDecision]:
"""Return the ordered issues to dispatch this tick (see module docstring).
Empty when the kill switch is on, the allowlist excludes everything, or no
issue clears every gate. At most one decision per repo; ordered
lowest-priority-value-first (most urgent), ties broken by ascending issue
number.
"""
# Kill switch: master off-ramp, evaluated before any per-issue work.
if config.kill_switch:
return []
allowlist = frozenset(config.allowlist)
# First pass: keep only issues that clear every per-issue gate. Repos already
# in flight are excluded here, so the lock is enforced before slot selection.
eligible: list[Issue] = [
issue
for issue in issues
if _is_eligible(issue, allowlist, in_flight_repos)
]
# One slot per repo: among the eligible issues sharing a repo, the best
# candidate (the global sort order) takes it; the rest are dropped this tick.
best_per_repo: dict[str, Issue] = {}
for issue in sorted(eligible, key=_dispatch_sort_key):
best_per_repo.setdefault(issue.repo, issue)
# Final order: the per-repo winners, most urgent first (total + stable).
winners = sorted(best_per_repo.values(), key=_dispatch_sort_key)
return [DispatchDecision(issue=issue, reason=_reason(issue)) for issue in winners]
# --------------------------------------------------------------------------- #
# Internals.
# --------------------------------------------------------------------------- #
def _is_eligible(
issue: Issue,
allowlist: frozenset[str],
in_flight_repos: set[str],
) -> bool:
"""True iff the issue clears the trust, allowlist, per-repo-lock, and
blocked_by gates. Kept boolean (not "which gate failed") because the policy
only ever needs the survivors; reasons are attached to survivors only."""
if not issue.labeled_by_trusted:
return False
if issue.repo not in allowlist:
return False
if issue.repo in in_flight_repos:
return False
if issue.blocked_by: # non-empty == at least one OPEN blocker remains
return False
return True
def _dispatch_sort_key(issue: Issue) -> tuple[int, int]:
"""Sort key giving a total, deterministic order: lowest ``priority`` value
first (P0 before P1 — most urgent wins), then lowest issue number as the
tiebreaker so equal-priority issues never depend on input/iteration order."""
return (issue.priority, issue.number)
def _reason(issue: Issue) -> str:
"""Human-readable justification, logged and surfaced in notifications, never
parsed. Records that every gate passed and the priority that ordered it."""
return (
f"{issue.repo}#{issue.number}: eligible "
f"(trusted, allowlisted, unblocked, repo free) — priority {issue.priority}"
)