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

116
app/afk/phase_checklist.py Normal file
View file

@ -0,0 +1,116 @@
"""Render an AFK run's progress as a live markdown checklist.
``render(current, meta)`` is a PURE function: it maps a ``Phase`` plus a bag of
optional context (``meta``) to a markdown task list, with no I/O and no hidden
state. The loop posts the result as an issue comment so a human glancing at the
tracker can see exactly how far an unattended run has got worktree created,
test written, green, pushed, CI, deployed, done.
The list always shows all seven lifecycle phases in order. Phases strictly
*before* ``current`` are checked (``- [x]``); ``current`` is marked in-progress
(``- [~]``); later phases are empty (``- [ ]``). ``Phase.DONE`` is terminal at
that point every line, including DONE itself, is checked.
``meta`` is best-effort decoration only. Recognised keys (all optional):
``repo`` / ``issue`` (header title), ``thread_id`` (header suffix), and
``fix_forward_attempts`` (a note line when non-zero). Unknown keys are ignored,
and a missing key never raises the checklist degrades gracefully to just the
phase list. Nothing here mutates ``meta``.
"""
from typing import Any
from .types import Phase
# Lifecycle order — the single source of truth for both ordering and the
# checked/active/empty partition. Must stay in sync with ``Phase`` (the
# checklist tests assert every phase appears, so a divergence is caught).
_ORDER: tuple[Phase, ...] = (
Phase.WORKTREE,
Phase.TESTS_RED,
Phase.GREEN,
Phase.PUSHED,
Phase.CI,
Phase.DEPLOYED,
Phase.DONE,
)
# Human-readable label per phase (what shows on each checklist line).
_LABELS: dict[Phase, str] = {
Phase.WORKTREE: "Worktree created",
Phase.TESTS_RED: "Failing test written (TDD red)",
Phase.GREEN: "Implementation passing (TDD green)",
Phase.PUSHED: "Pushed to master",
Phase.CI: "CI green on pushed commit",
Phase.DEPLOYED: "Deployed / rolled out",
Phase.DONE: "Done — issue closed",
}
# Task-list markers. ``[~]`` (in-progress) is a common markdown convention and,
# crucially, is neither ``[x]`` nor ``[ ]`` so the active line is always visually
# distinct from a checked or empty box.
_DONE = "- [x]"
_ACTIVE = "- [~]"
_TODO = "- [ ]"
def render(current: Phase, meta: dict[str, Any]) -> str:
"""Render the run's progress checklist as markdown (see module docstring).
``current`` is the phase the run is in right now; ``meta`` supplies optional
header/context fields. Pure: identical inputs yield byte-identical output and
``meta`` is never mutated.
"""
current_index = _ORDER.index(current)
is_done = current is Phase.DONE
lines = [_header(meta), ""]
for index, phase in enumerate(_ORDER):
lines.append(f"{_marker(index, current_index, is_done)} {_LABELS[phase]}")
note = _fix_forward_note(meta)
if note is not None:
lines.extend(["", note])
# Trailing newline so the block sits cleanly when concatenated into a comment.
return "\n".join(lines) + "\n"
def _marker(index: int, current_index: int, is_done: bool) -> str:
"""The checkbox marker for the phase at ``index`` given the current phase.
Earlier phases are checked; the current phase is in-progress; later phases
are empty. When the run is DONE, every phase (including DONE) is checked.
"""
if is_done or index < current_index:
return _DONE
if index == current_index:
return _ACTIVE
return _TODO
def _header(meta: dict[str, Any]) -> str:
"""The ``###`` title line. Includes ``repo#issue`` when both are present and
a ``(thread ...)`` suffix when a thread id is known; degrades to a bare title
otherwise."""
repo = meta.get("repo")
issue = meta.get("issue")
if repo is not None and issue is not None:
title = f"{repo}#{issue} — AFK run progress"
else:
title = "AFK run progress"
thread_id = meta.get("thread_id")
if thread_id:
title = f"{title} (thread {thread_id})"
return f"### {title}"
def _fix_forward_note(meta: dict[str, Any]) -> str | None:
"""A note line when one or more fix-forward attempts have happened, else
``None`` (no line). Zero/absent attempts add nothing the clean path stays
uncluttered."""
attempts = meta.get("fix_forward_attempts")
if not attempts:
return None
plural = "attempt" if attempts == 1 else "attempts"
return f"_Fix-forward: {attempts} {plural}._"