Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 96 additions & 3 deletions scripts/register_evolution_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,89 @@ def _install_evolution_helpers(repo_root: Path) -> list[str]:
return installed


# Labels required by the evolution pipeline. Kept in one place so every skill
# stage (issues, introspection, integration, implementation) can rely on them
# existing. Creation is idempotent; failures are warnings, not fatal.
_EVOLUTION_LABELS: list[tuple[str, str, str]] = [
("capability", "5319e7", "Missing ability users needed"),
("introspection", "0e8a16", "Found by session introspection"),
("ux", "fbca04", "Interaction friction"),
("proposal", "0e8a16", "Evolution-generated improvement proposal"),
("research-generated", "1d76db", "Created by the evolution research cycle"),
("needs-work", "d93f0b", "Blocked by code-review (dead code / not integrated)"),
("next-increment", "1d76db", "Roadmap increment merged; more deferred — re-queued"),
("accepted", "0e8a16", "Accepted by evolution — sent to a PR / implemented"),
("rejected", "b60205", "Not accepted by evolution — see closing comment"),
("needs-split", "d4c5f9", "Wanted, but exceeds one cycle — needs decomposition"),
("blocked", "e11d21", "Needs human/infrastructure action — see comment"),
("fix", "1d76db", "Bug or fix"),
("improvement", "a2eeef", "An improvement to existing functionality"),
(
"implemented-on-main",
"0e8a16",
"Capability already exists on main — no code change needed",
),
]


def _ensure_evolution_labels(repo_root: Path, dry_run: bool = False) -> list[str]:
"""Idempotently create the GitHub labels used by the evolution pipeline.

Several evolution skills call ``gh label create`` with the expectation that
the label exists; on a fresh fork the labels are missing and every label
operation fails silently (wasting API calls and leaving issues
uncategorized — #468). This bootstrap step runs once per registration pass.

Returns the list of label names that were created or confirmed present.
Warnings are printed for any failure, but registration continues.
"""
import subprocess

created: list[str] = []
for name, color, description in _EVOLUTION_LABELS:
cmd = [
"gh",
"label",
"create",
name,
"--repo",
"Lexus2016/hermes-agent-evolution",
"--color",
color,
"--description",
description,
]
if dry_run:
print(f"[evolution-cron] dry-run label: {name}")
created.append(name)
continue
try:
result = subprocess.run(
cmd,
cwd=repo_root,
capture_output=True,
text=True,
check=False,
timeout=30,
)
if result.returncode == 0:
created.append(name)
elif "already exists" in (result.stderr or "").lower():
created.append(name)
else:
print(
f"[evolution-cron] warning: could not create label {name}: "
f"{result.stderr or result.stdout}",
file=sys.stderr,
)
except Exception as exc: # pragma: no cover - gh may be missing
print(
f"[evolution-cron] warning: could not create label {name}: {exc}",
file=sys.stderr,
)
return created


def main(argv: list[str]) -> int:
dry_run = "--dry-run" in argv
positional = [a for a in argv[1:] if not a.startswith("--")]
Expand All @@ -178,6 +261,10 @@ def main(argv: list[str]) -> int:
# the process when needed, so nobody has to launch us with the right python.
_ensure_venv_python(repo_root, argv)

# Bootstrap the GitHub labels used by every evolution skill. Missing labels
# make issue/PR operations fail silently on fresh forks (#468).
label_ensured = [] if dry_run else _ensure_evolution_labels(repo_root)

src_dir = Path(positional[0]) if positional else repo_root / "cron" / "evolution"
if not src_dir.is_dir():
print(f"[evolution-cron] no evolution cron dir at {src_dir}", file=sys.stderr)
Expand Down Expand Up @@ -221,7 +308,11 @@ def main(argv: list[str]) -> int:
# executes the copy in HERMES_HOME/scripts; without this refresh the
# installed script stays frozen at whatever version existed when the
# job was first registered.
if spec.get("no_agent") and str(spec.get("script") or "").strip() and not dry_run:
if (
spec.get("no_agent")
and str(spec.get("script") or "").strip()
and not dry_run
):
_install_script(repo_root, str(spec["script"]).strip())

schedule = str(spec.get("schedule") or "").strip()
Expand Down Expand Up @@ -252,7 +343,9 @@ def main(argv: list[str]) -> int:
continue
changes: dict = {}
want_sched = parse_schedule(schedule).get("display", schedule)
cur_sched = (cur.get("schedule") or {}).get("display") or cur.get("schedule_display")
cur_sched = (cur.get("schedule") or {}).get("display") or cur.get(
"schedule_display"
)
if want_sched != cur_sched:
changes["schedule"] = schedule
if not no_agent:
Expand Down Expand Up @@ -325,7 +418,7 @@ def main(argv: list[str]) -> int:
print(
f"[evolution-cron] {verb}={len(created)} reconciled={len(updated)} "
f"skipped(unchanged)={len(skipped)} failed={len(failed)} "
f"helper_scripts_installed={len(helper_scripts)}"
f"helper_scripts_installed={len(helper_scripts)} labels_ensured={len(label_ensured)}"
)
for name, jid in created:
print(f" + {name} ({jid})")
Expand Down
66 changes: 60 additions & 6 deletions tests/scripts/test_register_evolution_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,7 @@ def test_no_agent_without_script_fails(self, tmp_path, monkeypatch):
src_dir = tmp_path / "cron-src"
src_dir.mkdir()
(src_dir / "bad.yaml").write_text(
"name: evolution-bad\n"
'schedule: "0 9 * * *"\n'
"no_agent: true\n"
'prompt: "x"\n'
'name: evolution-bad\nschedule: "0 9 * * *"\nno_agent: true\nprompt: "x"\n'
)
home = tmp_path / "hermes-home"
home.mkdir()
Expand Down Expand Up @@ -221,8 +218,9 @@ def _wire(self, mod, jobs_mod, monkeypatch, tmp_path, existing):
monkeypatch.setattr(
jobs_mod,
"update_job",
lambda job_id, updates: calls.update(job_id=job_id, updates=updates)
or {**existing, **updates},
lambda job_id, updates: (
calls.update(job_id=job_id, updates=updates) or {**existing, **updates}
),
)
return calls

Expand Down Expand Up @@ -348,3 +346,59 @@ def test_no_family_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))

assert mod._install_evolution_helpers(repo) == []


class TestEnsureEvolutionLabels:
"""``_ensure_evolution_labels`` idempotently creates the GitHub labels used
by every evolution skill. It must succeed when labels already exist and
surface (but not die on) genuine gh failures."""

def test_dry_run_lists_all_labels(self, tmp_path):
mod = _import_module()
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=True)
assert set(ensured) == {name for name, _, _ in mod._EVOLUTION_LABELS}

def test_already_existing_label_is_confirmed(self, tmp_path, monkeypatch):
mod = _import_module()
calls = []

def fake_run(cmd, **kwargs):
class _Result:
returncode = 1
stderr = f"HTTP 422: {cmd[3]} already exists"
stdout = ""

calls.append(cmd)
return _Result()

monkeypatch.setattr("subprocess.run", fake_run)
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=False)
assert set(ensured) == {name for name, _, _ in mod._EVOLUTION_LABELS}
assert len(calls) == len(mod._EVOLUTION_LABELS)
# cmd layout: gh label create <name> --repo <repo> --color <c> --description <d>
assert all(c[0] == "gh" and c[1] == "label" and c[2] == "create" for c in calls)
assert {c[3] for c in calls} == {name for name, _, _ in mod._EVOLUTION_LABELS}
assert all(
"--repo" in c and "--color" in c and "--description" in c for c in calls
)

def test_real_failure_is_warning_not_fatal(self, tmp_path, monkeypatch, capsys):
mod = _import_module()
bad_label = None

def fake_run(cmd, **kwargs):
class _Result:
returncode = 1
stderr = "HTTP 403: Forbidden"
stdout = ""

nonlocal bad_label
bad_label = cmd[3]
return _Result()

monkeypatch.setattr("subprocess.run", fake_run)
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=False)
assert ensured == []
captured = capsys.readouterr()
assert "warning: could not create label" in captured.err
assert bad_label in captured.err
Loading