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

43
app/afk/__init__.py Normal file
View file

@ -0,0 +1,43 @@
"""AFK loop: the autonomous issue-implementer control plane.
This package is the "away-from-keyboard" automation that watches the issue
tracker for ``ready-for-agent`` issues, dispatches each to a fresh **T3** thread
(the full-access ``claudeAgent`` runtime) with the issue-implementer preamble
prepended, then drives the resulting run through its lifecycle tests-red
green pushed CI deployed escalating or fix-forwarding per a small,
testable state machine. It owns no agent behaviour itself; the agent's standing
rules are injected as a prompt preamble (``issue_implementer_prompt``) because
T3 does NOT honour ``~/.claude/CLAUDE.md``.
The whole loop ships **DISABLED**, by two independent gates: ``Config`` defaults
to ``kill_switch=True`` AND an empty ``allowlist`` (see ``config.py``). Importing
this package, scheduling the CronJob entrypoints, or constructing the default
``Config`` therefore dispatches NOTHING and performs zero I/O a disabled tick
is wholly inert. The package is also not imported by the running service
(``app.main``), so wiring it in changes nothing on its own.
>>> ENABLING IS A DELIBERATE MANUAL STEP, PERFORMED LATER, NEVER BY THIS CODE. <<<
Arming the loop takes BOTH of, on purpose (either alone stays inert, so one
fat-fingered env var can't arm every repo):
1. clear the kill switch (``AFK_KILL_SWITCH=false`` / ConfigMap ``kill_switch: "false"``), AND
2. enrol the exact repos (``AFK_ALLOWLIST=repo-a,repo-b`` / ConfigMap ``allowlist``).
There is no auto-enable path anywhere in this package; do not add one here.
Every test in the suite runs against fakes this package never talks to a real
T3 server, GitHub/Forgejo, the cluster, or Slack.
Module map (each is independently testable against the interfaces in
``types.py``):
* ``types`` shared dataclasses + enums (the contract).
* ``config`` disabled-by-default Config + env/configmap loaders.
* ``issue_implementer_prompt`` the preamble prepended to every dispatch.
* ``dispatch_policy`` which ready issues to dispatch right now (pure).
* ``run_state_machine`` snapshot + CI status next Action (pure).
* ``phase_checklist`` render the run's progress as a markdown checklist (pure).
* ``t3_client`` the two-POST T3 dispatch + snapshot reader.
* ``tracker`` issue-tracker reads/labels/comments/close.
* ``ci_watcher`` commit CI status.
* ``notifier`` escalation/notification sink.
* ``poller`` CronJob tick #1: select + dispatch ready issues.
* ``watcher`` CronJob tick #2: drive one in-flight run to a verdict.
"""