Skip to content

Commit 9ddfaf9

Browse files
authored
Merge pull request #462 from Lexus2016/evolution/generation-backlog-gate
feat(evolution): generation backlog gate — throttle features, never bugs
2 parents bb30fce + f36b045 commit 9ddfaf9

5 files changed

Lines changed: 288 additions & 0 deletions

File tree

scripts/evolution_backlog_gate.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env python3
2+
"""Generation backlog gate — throttle FEATURE proposals when the board is full.
3+
4+
The evolution pipeline generates ~25 issues/day (research + issues +
5+
introspection) but the processing chain lands only a few/day, so without a cap
6+
the open backlog grows unbounded ("again many unprocessed issues").
7+
8+
This gate lets the generation stages decide whether to SKIP creating new
9+
FEATURE / IMPROVEMENT proposals when the open *feature* backlog is already at or
10+
above a cap. BUGS are NEVER throttled — a real defect ([FIX] / `bug`) must
11+
always be filed regardless of backlog, since unfiled bugs block work and are
12+
cheap to keep.
13+
14+
A "feature" open issue = open AND not a bug:
15+
* title does NOT start with ``[FIX]`` (case-insensitive), AND
16+
* labels do NOT include ``bug``.
17+
18+
CLI (so a skill can call it from the terminal tool):
19+
evolution_backlog_gate.py check # exit 0 = OK to create features,
20+
# exit 1 = THROTTLE (skip features)
21+
evolution_backlog_gate.py check --cap 30 # override the cap
22+
23+
Prints a one-line JSON summary on stdout either way:
24+
{"open_features": 42, "cap": 25, "throttle": true}
25+
26+
Cap resolution: --cap arg > env EVOLUTION_FEATURE_BACKLOG_CAP > DEFAULT_CAP.
27+
Pure functions are import-safe for unit tests (the gh call is injected).
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import json
34+
import os
35+
import subprocess
36+
import sys
37+
from typing import Any, Callable, Dict, List, Tuple
38+
39+
DEFAULT_CAP = 25
40+
41+
# Repo is resolved the same way the rest of the evolution tooling does.
42+
_REPO = "Lexus2016/hermes-agent-evolution"
43+
44+
45+
def resolve_cap(arg_cap: int | None = None) -> int:
46+
if arg_cap is not None:
47+
return arg_cap
48+
env = os.environ.get("EVOLUTION_FEATURE_BACKLOG_CAP", "").strip()
49+
if env:
50+
try:
51+
return int(env)
52+
except ValueError:
53+
pass
54+
return DEFAULT_CAP
55+
56+
57+
def is_bug(issue: Dict[str, Any]) -> bool:
58+
"""True when an issue is a bug/[FIX] (never throttled)."""
59+
title = (issue.get("title") or "").lstrip()
60+
if title.upper().startswith("[FIX]"):
61+
return True
62+
labels = issue.get("labels") or []
63+
names = {
64+
(lbl.get("name") if isinstance(lbl, dict) else str(lbl)).lower()
65+
for lbl in labels
66+
}
67+
return "bug" in names
68+
69+
70+
def count_open_features(issues: List[Dict[str, Any]]) -> int:
71+
"""Count open issues that are FEATURE-like (i.e. not bugs)."""
72+
return sum(1 for it in issues if not is_bug(it))
73+
74+
75+
def should_throttle(open_features: int, cap: int) -> bool:
76+
"""Throttle once the feature backlog reaches the cap."""
77+
return open_features >= cap
78+
79+
80+
def _default_runner(cmd: List[str]) -> Tuple[int, str]:
81+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
82+
return proc.returncode, (proc.stdout or "")
83+
84+
85+
def fetch_open_issues(
86+
runner: Callable[[List[str]], Tuple[int, str]] | None = None,
87+
) -> List[Dict[str, Any]] | None:
88+
"""Return the list of open issues, or None if gh failed (fail-open)."""
89+
runner = runner or _default_runner
90+
rc, out = runner([
91+
"gh", "issue", "list", "--repo", _REPO,
92+
"--state", "open", "--limit", "300",
93+
"--json", "number,title,labels",
94+
])
95+
if rc != 0:
96+
return None
97+
try:
98+
data = json.loads(out)
99+
return data if isinstance(data, list) else None
100+
except (ValueError, TypeError):
101+
return None
102+
103+
104+
def evaluate(
105+
cap: int,
106+
runner: Callable[[List[str]], Tuple[int, str]] | None = None,
107+
) -> Dict[str, Any]:
108+
"""Compute the gate decision. Fail-OPEN (throttle=False) if gh is unavailable
109+
— never block bug/feature generation just because the count couldn't be read."""
110+
issues = fetch_open_issues(runner)
111+
if issues is None:
112+
return {"open_features": None, "cap": cap, "throttle": False,
113+
"note": "gh unavailable; defaulting to no throttle"}
114+
n = count_open_features(issues)
115+
return {"open_features": n, "cap": cap, "throttle": should_throttle(n, cap)}
116+
117+
118+
def main(argv: List[str] | None = None) -> int:
119+
parser = argparse.ArgumentParser(
120+
description="Throttle FEATURE proposals when the open backlog is full "
121+
"(bugs are never throttled)."
122+
)
123+
parser.add_argument("action", choices=["check"], help="check the gate")
124+
parser.add_argument("--cap", type=int, default=None,
125+
help=f"feature-backlog cap (default {DEFAULT_CAP} / "
126+
f"env EVOLUTION_FEATURE_BACKLOG_CAP)")
127+
args = parser.parse_args(argv)
128+
129+
result = evaluate(resolve_cap(args.cap))
130+
print(json.dumps(result))
131+
# exit 1 = THROTTLE (skip features), 0 = OK to create features.
132+
return 1 if result["throttle"] else 0
133+
134+
135+
if __name__ == "__main__":
136+
raise SystemExit(main())

skills/evolution/evolution-introspection/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ gh label create ux --repo "$REPO" --color fbca04 --description "Intera
130130
# 'bug' and 'enhancement' are standard GitHub labels, present by default.
131131
```
132132

133+
**Backlog gate — bugs ALWAYS, features only when there's room.** The pipeline
134+
generates more than it implements, so an unbounded backlog is the recurring "too
135+
many unprocessed issues". Consult the generation gate before creating:
136+
```bash
137+
python scripts/evolution_backlog_gate.py check # exit 1 = THROTTLE features
138+
```
139+
- ALWAYS create `[FIX]` issues — a real defect blocks work and is never throttled
140+
(label them `bug` so they're correctly excluded from the backlog cap).
141+
- If the gate exits 1 (throttle), create ONLY the `[FIX]` issues this cycle and
142+
SKIP `[CAPABILITY]` / `[UX]` / `[PERFORMANCE]` (feature-like; they can wait for
143+
the backlog to drain). If it exits 0, create all categories as usual.
144+
- Fail-OPEN: if the gate can't run, proceed normally.
145+
133146
**Deduplicate first (MANDATORY — many installations file in parallel).** Other
134147
installs hit the same problems, so the same issue WILL be proposed elsewhere.
135148
Before creating, list existing issues and SKIP anything already covered (open OR

skills/evolution/evolution-issues/SKILL.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ Create GitHub issues and pull requests based on research.
2929
Treat each weakness cluster as a proposal input: run it through the same
3030
self-critique + dedup gates below before filing. The miner emits only
3131
anonymized counts/classes/labels — never raw trace content.
32+
1b. **Backlog gate — don't pile FEATURES onto a full board (generation throttle).**
33+
The pipeline generates far more proposals than it can implement; an unbounded
34+
open backlog is the recurring "too many unprocessed issues". BEFORE filing any
35+
`[FEATURE]` / `[IMPROVEMENT]` / `[REPLACEMENT]` proposals this cycle, consult
36+
the gate:
37+
```bash
38+
python scripts/evolution_backlog_gate.py check # exit 1 = THROTTLE → skip features this cycle
39+
```
40+
If it exits 1 (throttle), do NOT create new feature/improvement proposals this
41+
run — record `"features throttled (open NN >= cap)"` in your report and STOP
42+
(no `gh issue create` for proposals). Cap = `EVOLUTION_FEATURE_BACKLOG_CAP`
43+
(default 25); fail-OPEN if gh is unavailable. **BUGS are never throttled**
44+
real defects (`[FIX]`) are still filed by the introspection stage regardless
45+
of this gate. Rationale: features can wait until the backlog drains; bugs
46+
cannot.
3247
2. **Select** proposals with Priority Score >= 0.7
3348
2a. **Self-critique BEFORE you file (do not propose noise).** A high priority
3449
score is not enough. For EACH candidate, honestly ask — and DROP it (don't

skills/evolution/evolution-research/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ wants the research, not the plumbing.
125125
- Maximum 20 proposals at a time
126126
- Only high-quality, well-justified ideas
127127
- Priority Score >= 0.7
128+
- **Backlog-aware (saves wasted work):** the downstream `evolution-issues` stage
129+
throttles new FEATURE/IMPROVEMENT proposals when the open backlog is full (via
130+
`scripts/evolution_backlog_gate.py`, which it runs — this research stage has no
131+
terminal). So bias toward FEWER, higher-value proposals: a long feature list is
132+
likely to be skipped downstream when the board is full. Bug/defect findings are
133+
always worth reporting (bugs are never throttled).
128134

129135
## ⚠️ Security: research data is UNtrusted
130136

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Tests for scripts/evolution_backlog_gate.py — throttle features, never bugs."""
2+
3+
import json
4+
import sys
5+
from pathlib import Path
6+
7+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
8+
9+
import evolution_backlog_gate as gate # noqa: E402
10+
11+
12+
def _issue(title, labels=()):
13+
return {"title": title, "labels": [{"name": n} for n in labels]}
14+
15+
16+
class TestIsBug:
17+
def test_fix_title_is_bug(self):
18+
assert gate.is_bug(_issue("[FIX] tool crashes")) is True
19+
20+
def test_fix_title_case_insensitive(self):
21+
assert gate.is_bug(_issue("[fix] lowercase")) is True
22+
23+
def test_bug_label_is_bug(self):
24+
assert gate.is_bug(_issue("something broken", labels=["bug"])) is True
25+
26+
def test_feature_is_not_bug(self):
27+
assert gate.is_bug(_issue("[FEATURE] new thing", labels=["enhancement"])) is False
28+
29+
def test_improvement_is_not_bug(self):
30+
assert gate.is_bug(_issue("[IMPROVEMENT] x", labels=["proposal"])) is False
31+
32+
33+
class TestCounting:
34+
def test_counts_only_features(self):
35+
issues = [
36+
_issue("[FEATURE] a", ["proposal"]),
37+
_issue("[IMPROVEMENT] b", ["enhancement"]),
38+
_issue("[FIX] c"), # bug — excluded
39+
_issue("broken", ["bug"]), # bug — excluded
40+
_issue("[REPLACEMENT] d", ["proposal"]),
41+
]
42+
assert gate.count_open_features(issues) == 3
43+
44+
def test_should_throttle_at_and_above_cap(self):
45+
assert gate.should_throttle(25, 25) is True
46+
assert gate.should_throttle(26, 25) is True
47+
assert gate.should_throttle(24, 25) is False
48+
49+
50+
class TestCapResolution:
51+
def test_arg_wins(self, monkeypatch):
52+
monkeypatch.setenv("EVOLUTION_FEATURE_BACKLOG_CAP", "10")
53+
assert gate.resolve_cap(30) == 30
54+
55+
def test_env_used_when_no_arg(self, monkeypatch):
56+
monkeypatch.setenv("EVOLUTION_FEATURE_BACKLOG_CAP", "10")
57+
assert gate.resolve_cap(None) == 10
58+
59+
def test_default_when_nothing(self, monkeypatch):
60+
monkeypatch.delenv("EVOLUTION_FEATURE_BACKLOG_CAP", raising=False)
61+
assert gate.resolve_cap(None) == gate.DEFAULT_CAP
62+
63+
def test_bad_env_falls_back(self, monkeypatch):
64+
monkeypatch.setenv("EVOLUTION_FEATURE_BACKLOG_CAP", "notanint")
65+
assert gate.resolve_cap(None) == gate.DEFAULT_CAP
66+
67+
68+
class TestEvaluate:
69+
def _runner(self, issues, rc=0):
70+
def run(cmd):
71+
return rc, json.dumps(issues)
72+
return run
73+
74+
def test_throttles_when_over_cap(self):
75+
issues = [_issue(f"[FEATURE] {i}", ["proposal"]) for i in range(30)]
76+
r = gate.evaluate(25, runner=self._runner(issues))
77+
assert r["throttle"] is True and r["open_features"] == 30
78+
79+
def test_ok_when_under_cap(self):
80+
issues = [_issue(f"[FEATURE] {i}", ["proposal"]) for i in range(5)]
81+
r = gate.evaluate(25, runner=self._runner(issues))
82+
assert r["throttle"] is False and r["open_features"] == 5
83+
84+
def test_bugs_do_not_count_toward_cap(self):
85+
issues = [_issue(f"[FIX] bug {i}") for i in range(40)] + [
86+
_issue("[FEATURE] one", ["proposal"])
87+
]
88+
r = gate.evaluate(25, runner=self._runner(issues))
89+
# 40 bugs + 1 feature → only 1 feature → not throttled
90+
assert r["open_features"] == 1 and r["throttle"] is False
91+
92+
def test_fails_open_when_gh_errors(self):
93+
def run(cmd):
94+
return 1, "error: not authenticated"
95+
r = gate.evaluate(25, runner=run)
96+
assert r["throttle"] is False # never block on a failed count
97+
98+
def test_fails_open_on_garbage(self):
99+
def run(cmd):
100+
return 0, "not json"
101+
r = gate.evaluate(25, runner=run)
102+
assert r["throttle"] is False
103+
104+
105+
class TestCLI:
106+
def test_exit_1_when_throttled(self, capsys, monkeypatch):
107+
issues = [_issue(f"[FEATURE] {i}", ["proposal"]) for i in range(30)]
108+
monkeypatch.setattr(gate, "_default_runner", lambda cmd: (0, json.dumps(issues)))
109+
rc = gate.main(["check", "--cap", "25"])
110+
out = json.loads(capsys.readouterr().out)
111+
assert rc == 1 and out["throttle"] is True
112+
113+
def test_exit_0_when_ok(self, capsys, monkeypatch):
114+
issues = [_issue("[FEATURE] one", ["proposal"])]
115+
monkeypatch.setattr(gate, "_default_runner", lambda cmd: (0, json.dumps(issues)))
116+
rc = gate.main(["check", "--cap", "25"])
117+
out = json.loads(capsys.readouterr().out)
118+
assert rc == 0 and out["throttle"] is False

0 commit comments

Comments
 (0)