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

128 lines
4.8 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
"""Config loader for the AFK loop — DISABLED BY DEFAULT.
The whole loop ships off. A bare ``Config()`` (and therefore ``default()``,
``from_env()`` with nothing set, and ``from_configmap({})``) has
``kill_switch=True`` and an empty ``allowlist`` so nothing is ever
dispatched until an operator deliberately turns it on. Enabling is a TWO-part
manual step, on purpose:
1. set ``AFK_KILL_SWITCH=false`` (or ``kill_switch: "false"`` in the
ConfigMap), AND
2. populate ``AFK_ALLOWLIST`` with the exact repos that may be automated.
Either alone is inert: the kill switch off with an empty allowlist still
dispatches nothing, and a full allowlist with the kill switch on is frozen.
Both gates exist so a single fat-fingered env var can't accidentally arm the
loop across every repo.
``from_env`` reads process env; ``from_configmap`` reads an already-parsed
stringstring mapping (the shape a mounted ConfigMap gives you). They share one
parser so the two paths can't drift. Lists are comma-separated; booleans accept
the usual truthy spellings.
This module owns only *loading* a ``Config`` the dataclass itself lives in
``types`` and policy decisions live in ``dispatch_policy`` / ``run_state_machine``.
"""
import os
from collections.abc import Mapping
from .types import Config
# Env var names — also the ConfigMap keys (one source of truth for both paths).
ENV_ALLOWLIST = "AFK_ALLOWLIST"
ENV_KILL_SWITCH = "AFK_KILL_SWITCH"
ENV_IN_PROGRESS_LABEL = "AFK_IN_PROGRESS_LABEL"
ENV_READY_LABEL = "AFK_READY_LABEL"
ENV_BUDGET_USD = "AFK_BUDGET_USD"
ENV_FIX_FORWARD_MAX_ATTEMPTS = "AFK_FIX_FORWARD_MAX_ATTEMPTS"
ENV_FIX_FORWARD_MAX_SECONDS = "AFK_FIX_FORWARD_MAX_SECONDS"
# Spellings accepted as boolean true / false (case-insensitive). Anything else
# raises rather than silently defaulting — an unparseable kill-switch value must
# never be guessed safe-or-unsafe.
_TRUE = frozenset({"1", "true", "yes", "on"})
_FALSE = frozenset({"0", "false", "no", "off"})
def default() -> Config:
"""The disabled default Config: kill switch ON, allowlist EMPTY.
Equivalent to ``Config(allowlist=[], kill_switch=True)``; provided as a named
entry point so callers don't hardcode the disabled posture themselves.
"""
return Config(allowlist=[], kill_switch=True)
def from_env(env: Mapping[str, str] | None = None) -> Config:
"""Build a Config from environment variables (defaults to ``os.environ``).
Unset variables fall back to the disabled/contract defaults, so an
unconfigured process stays off.
"""
return _from_mapping(os.environ if env is None else env)
def from_configmap(data: Mapping[str, str]) -> Config:
"""Build a Config from a parsed ConfigMap (string→string mapping).
Identical semantics to ``from_env`` same keys, same parser but sourced
from a mounted ConfigMap's ``data`` rather than process env. An empty mapping
yields the disabled default.
"""
return _from_mapping(data)
# --------------------------------------------------------------------------- #
# Internals — one shared parser so env and ConfigMap paths can't diverge.
# --------------------------------------------------------------------------- #
def _from_mapping(data: Mapping[str, str]) -> Config:
base = default()
return Config(
allowlist=_parse_list(data.get(ENV_ALLOWLIST), base.allowlist),
kill_switch=_parse_bool(data.get(ENV_KILL_SWITCH), base.kill_switch),
in_progress_label=_nonempty(data.get(ENV_IN_PROGRESS_LABEL), base.in_progress_label),
ready_label=_nonempty(data.get(ENV_READY_LABEL), base.ready_label),
budget_usd=_parse_float(data.get(ENV_BUDGET_USD), base.budget_usd),
fix_forward_max_attempts=_parse_int(
data.get(ENV_FIX_FORWARD_MAX_ATTEMPTS), base.fix_forward_max_attempts
),
fix_forward_max_seconds=_parse_int(
data.get(ENV_FIX_FORWARD_MAX_SECONDS), base.fix_forward_max_seconds
),
)
def _parse_list(raw: str | None, fallback: list[str]) -> list[str]:
if raw is None:
return list(fallback)
return [item.strip() for item in raw.split(",") if item.strip()]
def _parse_bool(raw: str | None, fallback: bool) -> bool:
if raw is None:
return fallback
value = raw.strip().lower()
if value in _TRUE:
return True
if value in _FALSE:
return False
raise ValueError(f"unparseable boolean for AFK config: {raw!r}")
def _parse_int(raw: str | None, fallback: int) -> int:
if raw is None or not raw.strip():
return fallback
return int(raw.strip())
def _parse_float(raw: str | None, fallback: float) -> float:
if raw is None or not raw.strip():
return fallback
return float(raw.strip())
def _nonempty(raw: str | None, fallback: str) -> str:
if raw is None or not raw.strip():
return fallback
return raw.strip()