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:
parent
171857da6b
commit
2ef0db9a96
23 changed files with 4717 additions and 0 deletions
117
app/afk/dispatch_policy.py
Normal file
117
app/afk/dispatch_policy.py
Normal 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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue