afk: add the autonomous issue-implementer loop (SHIPS DISABLED)

Adds app/afk/ — the "away-from-keyboard" control plane that watches the
issue tracker for ready-for-agent issues, dispatches each to a fresh
full-access T3 thread (with the issue-implementer preamble prepended,
because T3 does not honour ~/.claude/CLAUDE.md), and drives the resulting
run through its lifecycle: tests-red -> green -> pushed -> CI -> deployed,
escalating or fix-forwarding via a small pure state machine.

The loop is split into pure cores (no I/O, exhaustively unit-tested) and
thin injected adapters (the only edges that ever touch T3, the tracker,
CI, or Slack — faked in every test, so nothing here talks to a real
server, GitHub/Forgejo, or the cluster):

  pure:     types, dispatch_policy, run_state_machine, phase_checklist,
            config, issue_implementer_prompt
  adapters: t3_client (two-POST dispatch + snapshot), tracker, ci_watcher,
            notifier
  loops:    poller  — CronJob tick #1: list_ready -> select_dispatchable
                      -> dispatch + stamp the in-progress lock (label only
                      AFTER a successful dispatch, so a failed dispatch
                      never leaves a phantom lock). Per-repo lock derived
                      from the ready set, since the CronJob is stateless
                      between ticks.
            watcher — CronJob tick #2: assemble RunState from snapshot +
                      CI -> next_action -> act (close on success; relabel
                      ready-for-human + ring the doorbell on the two
                      escalations; dispatch a corrective turn on
                      fix-forward; refresh the progress checklist).

SHIPS DISABLED, on purpose: Config defaults to kill_switch=True AND an
empty allowlist, so a freshly-loaded config dispatches nothing and does
zero I/O. The package is not imported by the running service and has no
auto-enable path. Arming it is a deliberate, later, manual step requiring
BOTH gates (clear the kill switch AND enrol the exact repos) so one
fat-fingered env var can't arm every repo.

Test-first throughout: 412 tests pass (poller + watcher add integration
tests wiring the real pure cores to in-memory fakes). mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-15 21:15:11 +00:00
parent 171857da6b
commit 2ef0db9a96
23 changed files with 4717 additions and 0 deletions

117
app/afk/dispatch_policy.py Normal file
View file

@ -0,0 +1,117 @@
"""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
highest-priority eligible issue in that repo wins the slot. (A higher-priority
issue that is itself ineligible does not consume the slot the best
*eligible* candidate does.)
* **Priority ordering** the surviving per-repo winners are returned
highest-``priority``-first, with a deterministic tiebreaker (ascending issue
number) so the output is a total, stable order independent of input order.
PRIORITY DIRECTION note the deliberate divergence: ``Issue.priority``'s
docstring in ``types`` says "lower runs first", but this module follows the
explicit dispatch-policy specification, which orders **higher priority first**.
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
highest-priority-first, 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, highest priority 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: highest ``priority`` first
(negated so a plain ascending sort puts it on top), 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}"
)