Skip to content

Commit ffaf2ce

Browse files
Vaultifactsclaude
andcommitted
[AUTO] feat: PATCH_REVIEW_STATS_PATH canonical, /health/detailed patch_review, /patch_review bot cmd, reset endpoint v8.2.18 — 5418 tests
- api/constants.py: PATCH_REVIEW_STATS_PATH single source of truth - executor.py: imports PATCH_REVIEW_STATS_PATH from constants (fallback hardcoded) - patch_review.py: uses constants path; adds POST /health/patch-review/reset - health_detailed.py: includes patch_review check (informational, not in overall_ok) - bot_slash.py: /patch_review slash command (rejections, escalations, SDK mode, recent steps) - 5 new tests: reset endpoint, missing file safe, /health/detailed patch_review field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 31705bc commit ffaf2ce

7 files changed

Lines changed: 150 additions & 10 deletions

File tree

solo_builder/api/blueprints/health_detailed.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,25 @@ def health_detailed():
153153
repo_health_check = {"ok": True, "available": False, "error": str(exc),
154154
"active_agents": [], "outcome_stats": {}}
155155

156+
# --- patch_review (informational — escalations indicate quality issues) ---
157+
try:
158+
from .patch_review import _load_stats as _pr_load
159+
pr = _pr_load()
160+
patch_review_check = {
161+
"ok": True,
162+
"enabled": pr.get("enabled", True),
163+
"available": pr.get("available", False),
164+
"threshold_hits": pr.get("threshold_hits", 0),
165+
"total_rejections": pr.get("total_rejections", 0),
166+
}
167+
except Exception as exc:
168+
patch_review_check = {"ok": True, "enabled": True, "available": False,
169+
"threshold_hits": 0, "total_rejections": 0,
170+
"error": str(exc)}
171+
156172
overall_ok = (state_check["ok"] and drift_check["ok"]
157173
and alert_check["ok"] and slo_check_result["ok"])
158-
# repo_health is intentionally excluded from overall_ok — AAWO absence is informational
174+
# repo_health and patch_review are informational — excluded from overall_ok
159175

160176
return jsonify({
161177
"ok": overall_ok,
@@ -165,5 +181,6 @@ def health_detailed():
165181
"metrics_alerts": alert_check,
166182
"slo_status": slo_check_result,
167183
"repo_health": repo_health_check,
184+
"patch_review": patch_review_check,
168185
},
169186
})

solo_builder/api/blueprints/patch_review.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
"""PatchReviewer stats endpoint — GET /health/patch-review.
1+
"""PatchReviewer stats endpoint — GET /health/patch-review + POST /health/patch-review/reset.
22
33
Reads the stats snapshot written by executor.py after each review_step()
44
call. Returns current threshold_hits, per-subtask rejection counts,
55
SDK availability, and per-step review history so the dashboard can surface
66
review quality metrics.
77
8-
Response shape:
8+
Response shape (GET):
99
{
1010
"ok": bool,
1111
"enabled": bool,
@@ -22,18 +22,22 @@
2222
"escalated": int, "deferred": int}
2323
]
2424
}
25+
26+
Response shape (POST reset):
27+
{"ok": bool, "reset": bool}
2528
"""
2629
from __future__ import annotations
2730

2831
import json
29-
from pathlib import Path
3032

3133
from flask import Blueprint, jsonify
3234

35+
from ..constants import PATCH_REVIEW_STATS_PATH
36+
3337
patch_review_bp = Blueprint("patch_review", __name__)
3438

35-
_SOLO = Path(__file__).resolve().parents[3]
36-
_STATS_PATH = _SOLO / "state" / "patch_review_stats.json"
39+
# Expose for test patching
40+
_STATS_PATH = PATCH_REVIEW_STATS_PATH
3741

3842

3943
def _load_stats() -> dict:
@@ -58,3 +62,14 @@ def health_patch_review():
5862
"rejected_subtasks": s.get("rejected_subtasks", []),
5963
"recent_reviews": s.get("recent_reviews", []),
6064
})
65+
66+
67+
@patch_review_bp.post("/health/patch-review/reset")
68+
def reset_patch_review():
69+
"""Delete the stats file so PatchReviewer counters start fresh next session."""
70+
try:
71+
_STATS_PATH.unlink(missing_ok=True)
72+
reset = True
73+
except Exception:
74+
reset = False
75+
return jsonify({"ok": True, "reset": reset})

solo_builder/api/constants.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
CACHE_DIR = Path(os.environ.get("CACHE_DIR",
3232
str(_PROJECT_ROOT.parent / "claude" / "cache")))
3333

34-
DAG_EXPORT_PATH = _PROJECT_ROOT / "dag_export.json"
35-
DAG_IMPORT_TRIGGER = _PROJECT_ROOT / "state" / "dag_import_trigger.json"
34+
DAG_EXPORT_PATH = _PROJECT_ROOT / "dag_export.json"
35+
DAG_IMPORT_TRIGGER = _PROJECT_ROOT / "state" / "dag_import_trigger.json"
36+
PATCH_REVIEW_STATS_PATH = _PROJECT_ROOT / "state" / "patch_review_stats.json"
3637

3738
_CONFIG_DEFAULTS = {
3839
"STALL_THRESHOLD": 5,

solo_builder/discord_bot/bot_slash.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ async def help_cmd(interaction: discord.Interaction) -> None:
5858
"`/task_progress task_id` — per-branch progress for a single task\n"
5959
"`/heartbeat` — live counters from step.txt\n"
6060
"`/cache [clear:yes]` — response cache disk stats (optional wipe)\n"
61+
"`/patch_review` — PatchReviewer stats (rejections, escalations, SDK mode)\n"
6162
"`/help` — this message"
6263
)
6364

@@ -705,3 +706,54 @@ async def undepends_cmd(interaction: discord.Interaction, target: str, dep: str)
705706
f"✅ Undepends queued: Task {target} no longer depends on Task {dep}\n"
706707
f"CLI will apply at the next step boundary."
707708
)
709+
710+
@bot.tree.command(name="patch_review", description="Show PatchReviewer stats (rejections, escalations, SDK mode)")
711+
async def patch_review_cmd(interaction: discord.Interaction) -> None:
712+
if not _b._allowed(interaction):
713+
await interaction.response.send_message("❌ Wrong channel.", ephemeral=True)
714+
return
715+
try:
716+
from api.constants import PATCH_REVIEW_STATS_PATH
717+
stats_path = PATCH_REVIEW_STATS_PATH
718+
if stats_path.exists():
719+
s = json.loads(stats_path.read_text(encoding="utf-8"))
720+
else:
721+
s = {}
722+
except Exception:
723+
s = {}
724+
725+
enabled = s.get("enabled", True)
726+
available = s.get("available", False)
727+
use_sdk = s.get("use_sdk", True)
728+
hits = s.get("threshold_hits", 0)
729+
total_rej = s.get("total_rejections", 0)
730+
max_rej = s.get("max_rejections", 3)
731+
rejected = s.get("rejected_subtasks", [])
732+
recent = s.get("recent_reviews", [])
733+
734+
sdk_mode = "SDK" if available else ("heuristic-only" if enabled else "disabled")
735+
lines = [
736+
f"**PatchReviewer** · {sdk_mode}",
737+
f"⚠ {hits} escalated · ✗ {total_rej} rejected (limit {max_rej}/subtask)",
738+
]
739+
740+
if rejected:
741+
lines.append("**Rejections by subtask:**")
742+
for r in rejected[:8]:
743+
lines.append(f" `{r['name']}` ×{r['count']}{r.get('last_reason', '')[:60]}")
744+
if len(rejected) > 8:
745+
lines.append(f" … and {len(rejected) - 8} more")
746+
747+
if recent:
748+
lines.append("**Recent steps:**")
749+
for rv in recent[-5:]:
750+
lines.append(
751+
f" step {rv['step']}: "
752+
f"✓{rv.get('approved',0)}{rv.get('rejected',0)} "
753+
f"⚠{rv.get('escalated',0)}"
754+
)
755+
756+
if not s:
757+
lines.append("_(no stats file — executor not yet run)_")
758+
759+
await interaction.response.send_message("\n".join(lines))

solo_builder/runners/executor.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@
4040

4141
logger = logging.getLogger("solo_builder")
4242

43-
_METRICS_PATH = os.path.join(_SOLO, "metrics.jsonl")
44-
_PATCH_STATS_PATH = os.path.join(_SOLO, "state", "patch_review_stats.json")
43+
_METRICS_PATH = os.path.join(_SOLO, "metrics.jsonl")
44+
45+
# Single source of truth: api/constants.py defines the canonical path.
46+
# Import as a string to keep executor independent of the Flask layer.
47+
try:
48+
from api.constants import PATCH_REVIEW_STATS_PATH as _PR_STATS
49+
_PATCH_STATS_PATH = str(_PR_STATS)
50+
except Exception:
51+
_PATCH_STATS_PATH = os.path.join(_SOLO, "state", "patch_review_stats.json")
4552

4653

4754
class _BudgetAdapter:

solo_builder/tests/test_patch_review_blueprint.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,50 @@ def test_patch_review_in_dashboard_js(self):
201201
src = dash.read_text(encoding="utf-8")
202202
self.assertIn("pollPatchReviewDetailed", src)
203203

204+
# ── Reset endpoint ───────────────────────────────────────────────────
205+
206+
def test_reset_returns_ok(self):
207+
resp = self.client.post("/health/patch-review/reset")
208+
self.assertEqual(resp.status_code, 200)
209+
d = json.loads(resp.data)
210+
self.assertTrue(d["ok"])
211+
212+
def test_reset_deletes_stats_file(self):
213+
import api.blueprints.patch_review as pr_mod
214+
with tempfile.TemporaryDirectory() as tmp:
215+
p = self._write_stats(tmp, {"threshold_hits": 5, "total_rejections": 2,
216+
"enabled": True, "available": True, "use_sdk": True,
217+
"max_rejections": 3, "rejected_subtasks": [],
218+
"recent_reviews": []})
219+
with patch.object(pr_mod, "_STATS_PATH", p):
220+
resp = self.client.post("/health/patch-review/reset")
221+
d = json.loads(resp.data)
222+
self.assertTrue(d["reset"])
223+
self.assertFalse(p.exists())
224+
225+
def test_reset_missing_file_still_returns_ok(self):
226+
import api.blueprints.patch_review as pr_mod
227+
with patch.object(pr_mod, "_STATS_PATH", Path("/nonexistent/x.json")):
228+
resp = self.client.post("/health/patch-review/reset")
229+
d = json.loads(resp.data)
230+
self.assertTrue(d["ok"])
231+
232+
def test_reset_route_in_app(self):
233+
rules = [r.rule for r in app.url_map.iter_rules()]
234+
self.assertIn("/health/patch-review/reset", rules)
235+
236+
# ── /health/detailed includes patch_review ───────────────────────────
237+
238+
def test_health_detailed_includes_patch_review(self):
239+
resp = self.client.get("/health/detailed")
240+
self.assertEqual(resp.status_code, 200)
241+
d = json.loads(resp.data)
242+
self.assertIn("patch_review", d["checks"])
243+
pr = d["checks"]["patch_review"]
244+
self.assertIn("ok", pr)
245+
self.assertIn("threshold_hits", pr)
246+
self.assertIn("total_rejections", pr)
247+
204248
def test_patch_review_div_in_dashboard_html(self):
205249
html = (
206250
Path(__file__).resolve().parents[1]

tools/generate_openapi.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@
236236
"threshold_hits": {"type": "integer"}, "total_rejections": {"type": "integer"},
237237
"max_rejections": {"type": "integer"}, "rejected_subtasks": {"type": "array"},
238238
}}},
239+
{"path": "/health/patch-review/reset", "method": "POST", "tag": "Health", "summary": "Reset PatchReviewer stats (deletes stats snapshot file)",
240+
"response": {"type": "object", "properties": {
241+
"ok": {"type": "boolean"}, "reset": {"type": "boolean"},
242+
}}},
239243
# Policy
240244
{"path": "/policy/hitl", "method": "GET", "tag": "Policy", "summary": "HITL policy rules from settings.json",
241245
"response": {"type": "object", "properties": {

0 commit comments

Comments
 (0)