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>
258 lines
9.5 KiB
Python
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:]))
|