claude-agent-service/app/afk/phase_checklist.py

117 lines
4.3 KiB
Python
Raw Normal View History

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>
2026-06-15 21:15:11 +00:00
"""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}._"