atlas-iac/services/hermes/scripts/kanban_supervisor.py
jenkins 6a7009b31c hermes: add in-pod autonomous kanban supervisor
Drive the cross-card implement->review->repair->re-review chain from inside
the pod so unattended runs no longer stall once the first implementation card
completes. Today that chain exists only as an external codex-shepherd session;
this adds a bounded in-pod poll loop that reads board state via
hermes_cli.kanban_db and creates Kanban follow-up cards (subscription lanes
only) with no provider/metered path of its own.

- kanban_supervisor.py (I/O shell) + supervisor_policy.py (pure state machine):
  impl-done+PR -> review; review SHIP -> mark impl ready-for-human (never
  merges/approves/clears WIP); review BLOCK -> bounded repair; repair new
  commit -> re-review. Fail-closed on unparseable/ambiguous state; per-
  (parent, head_commit) dedup safe beside the external shepherd; bounded review
  <->repair cycle count and max concurrent chains.
- Deployed as a hardened non-root sidecar (drop ALL caps, read-only rootfs, no
  runtime-access/credential mount) alongside model-steward; scripts packaged in
  the coordinator configMapGenerator.
- Gated by new kanban.auto_supervise config key (default false, re-read each
  tick like auto_decompose) so it is inert until the external shepherd retires.
- Fix latent goal_max_turns NULL fallback in cli_lane_execution (1 -> documented
  default 20).
- 62 new behavioral tests at 100% line+branch on both modules.

Stacks on the merge train (base 5f27e50c).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:28:49 -03:00

258 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""In-pod autonomous supervisor that sustains multi-hour unattended work.
The deployed Hermes agent already decomposes objectives and iterates a within-
card goal loop, but nothing in-pod spawns the *cross-card* review->repair->
re-review follow-ups after a card finishes; today that chain only exists as an
external Claude Code "codex-shepherd" session on an operator's workstation, so
unattended runs stall once the first implementation card completes. This
supervisor closes that gap from inside the pod.
It runs a bounded poll loop that reads board state through ``hermes_cli.kanban_db``
and, for each terminal card, applies exactly one follow-up:
* a done implementation with a produced PR/commit and no existing review ->
create a review card (routed to a review assignee via the normal cli lane);
* a review card that returned SHIP -> mark the implementation ready for a human
to merge (a comment/flag; the supervisor never merges, approves, or clears WIP);
* a review card that returned BLOCK -> create a bounded repair card;
* a repair card that produced a new commit -> create the re-review card.
All decision logic lives in :mod:`supervisor_policy` (pure, unit tested). This
module is only the I/O shell: config, board iteration, and turning a
:class:`supervisor_policy.Decision` into ``create_task``/``block_task``/
``add_comment`` calls. It imports no provider client and holds no metered path;
the sole spawn is a Kanban card that routes through the existing subscription
lanes. It is inert until ``kanban.auto_supervise`` is set true in the deployed
config, re-read every tick like ``kanban.auto_decompose``.
"""
from __future__ import annotations
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
import yaml
import supervisor_policy as policy
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
CONFIG_PATH = DATA_ROOT / "config.yaml"
DEFAULT_INTERVAL_SECONDS = 30
DEFAULT_MAX_CYCLES = 5
DEFAULT_MAX_CHAINS = 20
DEFAULT_REVIEW_ASSIGNEE = "cli-claude-xhigh"
DEFAULT_REPAIR_ASSIGNEE = "cli-auto"
@dataclass(frozen=True)
class Settings:
"""Per-tick configuration, re-read from the deployed config each pass."""
enabled: bool
interval: int
limits: policy.Limits
def _kanban_config() -> dict[str, Any]:
try:
document = yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return {}
kanban = document.get("kanban") if isinstance(document, dict) else None
return kanban if isinstance(kanban, dict) else {}
def _bool(value: Any, default: bool) -> bool:
return value if isinstance(value, bool) else default
def _positive_int(value: Any, default: int) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return default
parsed = int(value)
return parsed if parsed > 0 else default
def _text(value: Any, default: str) -> str:
return value if isinstance(value, str) and value.strip() else default
def load_settings() -> Settings:
"""Read the ``kanban`` config block; default to inert and conservative."""
cfg = _kanban_config()
return Settings(
enabled=_bool(cfg.get("auto_supervise"), False),
interval=_positive_int(
cfg.get("supervise_interval_seconds"), DEFAULT_INTERVAL_SECONDS
),
limits=policy.Limits(
max_cycles=_positive_int(cfg.get("supervise_max_cycles"), DEFAULT_MAX_CYCLES),
max_chains=_positive_int(cfg.get("supervise_max_chains"), DEFAULT_MAX_CHAINS),
review_assignee=_text(
cfg.get("supervise_review_assignee"), DEFAULT_REVIEW_ASSIGNEE
),
repair_assignee=_text(
cfg.get("supervise_repair_assignee"), DEFAULT_REPAIR_ASSIGNEE
),
),
)
def _board_slug(board: Any) -> str:
if isinstance(board, dict):
return str(board.get("slug") or board.get("id") or "")
return str(getattr(board, "slug", None) or getattr(board, "id", None) or board or "")
def _log(message: str) -> None:
print(f"kanban-supervisor: {message}", file=sys.stderr, flush=True)
def _iter_boards(kanban_db: Any) -> list[str]:
try:
boards = kanban_db.list_boards(include_archived=False)
except Exception as error: # noqa: BLE001 - one bad registry never stops the tick
_log(f"could not list boards: {error}")
return []
slugs = [_board_slug(raw) for raw in boards]
return [slug for slug in slugs if slug]
def _comment(kanban_db: Any, conn: Any, task_id: str, body: str) -> None:
try:
kanban_db.add_comment(conn, task_id, policy.SUPERVISOR_AUTHOR, body)
except Exception as error: # noqa: BLE001 - a comment is best-effort signalling
_log(f"could not comment on {task_id}: {error}")
def _mark_ready_for_human(kanban_db: Any, conn: Any, task_id: str, body: str) -> None:
"""Flag the implementation as human-mergeable. Never merges or clears WIP."""
setter = getattr(kanban_db, "set_task_metadata", None) or getattr(
kanban_db, "update_task_metadata", None
)
if callable(setter):
try:
setter(conn, task_id, {"supervisor_ready_for_human_merge": True})
except Exception as error: # noqa: BLE001 - the comment is the durable flag
_log(f"could not set ready flag on {task_id}: {error}")
_comment(kanban_db, conn, task_id, body)
def _escalate(kanban_db: Any, conn: Any, task_id: str, reason: str) -> None:
body = (
f"supervisor fail-closed escalation: {reason}. Human attention required; "
"no follow-up card was auto-created."
)
try:
kanban_db.block_task(conn, task_id, reason=body, kind="supervisor")
except Exception as error: # noqa: BLE001 - still record the human-visible comment
_log(f"could not block {task_id}: {error}")
_comment(kanban_db, conn, task_id, body)
def _spawn(kanban_db: Any, conn: Any, decision: policy.Decision) -> None:
payload = dict(decision.payload or {})
try:
kanban_db.create_task(conn, **payload)
except TypeError:
# Older runtimes may not accept idempotency_key; the pre-spawn dedup scan
# in supervisor_policy still prevents a duplicate, so drop it and retry.
payload.pop("idempotency_key", None)
kanban_db.create_task(conn, **payload)
_comment(kanban_db, conn, decision.target_id, f"supervisor: {decision.reason}")
def apply_decision(
kanban_db: Any, conn: Any, decision: policy.Decision
) -> bool:
"""Execute one decision. Returns True when an action was taken."""
if decision.action == "none":
return False
policy.assert_safe(decision)
if decision.action == "spawn":
_spawn(kanban_db, conn, decision)
return True
if decision.action == "ship":
info = decision.payload or {}
commit = info.get("commit", "")
pr = info.get("pr", "") or "branch on record"
body = (
f"supervisor: review returned SHIP for commit {commit} ({pr}). "
"READY FOR HUMAN MERGE - a human must merge; the supervisor never "
"merges, approves, closes, or clears WIP."
)
_mark_ready_for_human(kanban_db, conn, decision.target_id, body)
return True
if decision.action == "escalate":
_escalate(kanban_db, conn, decision.target_id, decision.reason)
return True
# Unreachable given assert_safe, but fail closed on any future action name.
raise ValueError(f"unhandled supervisor action: {decision.action!r}") # pragma: no cover
def supervise_board(kanban_db: Any, board: str, limits: policy.Limits) -> int:
actions = 0
with kanban_db.scoped_current_board(board):
conn = kanban_db.connect(board=board)
try:
tasks = list(kanban_db.list_tasks(conn))
for task in tasks:
decision = policy.plan(task, tasks, limits)
try:
if apply_decision(kanban_db, conn, decision):
actions += 1
except Exception as error: # noqa: BLE001 - one card never stops others
_log(f"action failed on board {board!r}: {error}")
finally:
conn.close()
return actions
def supervise_once(kanban_db: Any, limits: policy.Limits) -> int:
"""One bounded pass over every non-archived board."""
total = 0
for board in _iter_boards(kanban_db):
try:
total += supervise_board(kanban_db, board, limits)
except Exception as error: # noqa: BLE001 - isolate a faulty board
_log(f"temporarily skipping board {board!r}: {error}")
return total
def run_forever(
kanban_db: Any,
*,
sleep: Callable[[float], None] = time.sleep,
load: Callable[[], Settings] = load_settings,
max_ticks: int | None = None,
) -> int:
"""Poll loop. Inert while ``auto_supervise`` is false; re-reads config a tick."""
ticks = 0
while max_ticks is None or ticks < max_ticks:
settings = load()
if settings.enabled:
try:
supervise_once(kanban_db, settings.limits)
except Exception as error: # noqa: BLE001 - never let one tick kill the loop
_log(f"tick failed: {error}")
sleep(settings.interval)
ticks += 1
return ticks
def main(argv: list[str] | None = None) -> int:
from hermes_cli import kanban_db
run_forever(kanban_db)
return 0
if __name__ == "__main__": # pragma: no cover - process entry point
sys.exit(main(sys.argv[1:]))