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
116
app/afk/phase_checklist.py
Normal file
116
app/afk/phase_checklist.py
Normal 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}._"
|
||||
Loading…
Add table
Add a link
Reference in a new issue