v0.1 wave 3 (steps 9+10): autonomous patch loop + production recipes

Step 9 — autonomous patch loop:
- patcher.py: clawdforge session → unified diff → worktree apply → verify recipe → push branch → open Gitea PR
- migration 007: patch_attempts (UNIQUE per finding+attempt, max 3 attempts)
- runner.py: post-parse hook fires patcher.maybe_draft_for_job when notify.auto_patch=true
- server.py: POST /jobs/{id}/patches, GET /patches, GET /patches/{id}
- digest.py: patch-drafted lines + open-follow-up count via Gitea PR state check
- mcp: crafting_table_draft_patch stub replaced with real implementation
- tests/test_patcher.py + tests/test_patches_api.py: 27 new tests

No auto-merge — patches stop at PR-open. Cobb merges.

Step 10 — production recipes:
- examples/recipes/clawdforge.json: 14 subprojects across all SDKs, audit nightly
- examples/recipes/cauldron.json: single Flask subproject, audit nightly
- examples/recipes/tradecraft.json: nightly audit, auto_patch=false (manual review)
- examples/register-all.sh: bulk-register helper with GITEA_TOKEN substitution
- README "Autonomous patch loop" + "First production recipes" sections

Tests: server 116→143, mcp 65→67. All green.

Spec: memory/spec-crafting-table.md
This commit is contained in:
Kayos 2026-04-29 09:04:48 -07:00
parent ecb9d76e6d
commit 4eab869df0
17 changed files with 2752 additions and 78 deletions

View file

@ -82,6 +82,25 @@ class SmtpConfig:
# --- helpers ----------------------------------------------------------------
def _parse_pr_url(pr_url: str) -> tuple[str, str, int] | None:
"""Pull (owner, repo, number) out of a Gitea-style PR URL.
Accepts URLs like ``http://192.168.0.5:3001/Sulkta-Coop/clawdforge/pulls/42``.
Returns None if the URL doesn't look right — caller treats that as
"can't determine state, assume open".
"""
try:
from urllib.parse import urlparse
u = urlparse(pr_url)
parts = [p for p in u.path.split("/") if p]
# owner/repo/pulls/N
if len(parts) >= 4 and parts[-2] in ("pulls", "issues"):
return parts[-4], parts[-3], int(parts[-1])
except (ValueError, TypeError):
return None
return None
def _job_event_tags(job: dict, findings: list[dict]) -> set[str]:
"""Map a job + its findings to notify.on event tags.
@ -167,10 +186,16 @@ def _filter_for_project(jobs_with_findings: list[tuple[dict, list[dict]]], notif
# --- rendering --------------------------------------------------------------
def _render_text(date_str: str, sections: list[dict], full_log_url: str) -> str:
def _render_text(
date_str: str,
sections: list[dict],
full_log_url: str,
*,
open_followups: int = 0,
) -> str:
"""Build the text body. Matches the worked example in the spec."""
total_runs = sum(len(s["runs"]) for s in sections)
total_drafted = 0 # placeholder, wave 3
total_drafted = sum(len(s.get("patches", [])) for s in sections)
total_cves = sum(s["cves"] for s in sections)
subj_summary = f"{total_runs} build" + ("s" if total_runs != 1 else "")
lines = []
@ -187,19 +212,32 @@ def _render_text(date_str: str, sections: list[dict], full_log_url: str) -> str:
lines.append(
f" {glyph} {proj_sub:<32s} {run['recipe']:<6s} {run['status']:<5s} ({run['summary']})"
)
for patch in s.get("patches", []):
if patch.get("branch_name"):
lines.append(
f" → patch drafted: branch {patch['branch_name']}"
)
if patch.get("pr_url"):
lines.append(f" → PR: {patch['pr_url']}")
lines.append("")
lines.append("Open follow-ups:")
lines.append(" - 0 unmerged auto-patches")
lines.append(f" - {open_followups} unmerged auto-patches")
lines.append(" - 0 manual review tickets in bugs.sulkta.com")
lines.append("")
lines.append(f"Full log: {full_log_url}")
return "\n".join(lines) + "\n"
def _render_html(date_str: str, sections: list[dict], full_log_url: str) -> str:
def _render_html(
date_str: str,
sections: list[dict],
full_log_url: str,
*,
open_followups: int = 0,
) -> str:
"""Build the HTML body. Same content, table styling, monospace font."""
total_runs = sum(len(s["runs"]) for s in sections)
total_drafted = 0
total_drafted = sum(len(s.get("patches", [])) for s in sections)
total_cves = sum(s["cves"] for s in sections)
rows = []
@ -211,6 +249,16 @@ def _render_html(date_str: str, sections: list[dict], full_log_url: str) -> str:
f"<td>{run['recipe']}</td><td>{run['status']}</td>"
f"<td>{run['summary']}</td></tr>"
)
for patch in s.get("patches", []):
cell = ""
if patch.get("branch_name"):
cell += f"branch <code>{patch['branch_name']}</code>"
if patch.get("pr_url"):
cell += f" — <a href=\"{patch['pr_url']}\">PR</a>"
if cell:
rows.append(
f'<tr><td>↳</td><td colspan="4">{cell}</td></tr>'
)
if not rows:
rows.append('<tr><td colspan="5"><i>(no activity)</i></td></tr>')
@ -234,7 +282,7 @@ tr td:first-child {{ font-size: 1.2em; }}
</table>
<h3>Open follow-ups</h3>
<ul>
<li>0 unmerged auto-patches</li>
<li>{open_followups} unmerged auto-patches</li>
<li>0 manual review tickets in bugs.sulkta.com</li>
</ul>
<p class="foot">Full log: <a href="{full_log_url}">{full_log_url}</a></p>
@ -267,6 +315,7 @@ class DigestScheduler:
hour: int = 6,
minute: int = 0,
full_log_base_url: str = "http://192.168.0.5:8810/digests",
gitea_pr_state_check=None,
):
self.db = db
self.smtp = smtp
@ -274,6 +323,10 @@ class DigestScheduler:
self.hour = hour
self.minute = minute
self.full_log_base_url = full_log_base_url
# Optional callable: (owner, repo, number) -> "open" | "closed" | None.
# Used to count open follow-ups across all PR-opened patches in the
# window. Tests inject a stub so we don't make real network calls.
self.gitea_pr_state_check = gitea_pr_state_check
self._loop_task: asyncio.Task | None = None
self._stopping = False
@ -389,6 +442,8 @@ class DigestScheduler:
per_project_sections: list[dict] = []
per_project_meta: list[dict] = []
full_log_url = f"{self.full_log_base_url}/{date_str}"
# Total open follow-ups across all projects in the window.
open_followups_total = 0
for prow in projects:
recipe = json.loads(prow.get("recipe_json") or "{}")
@ -421,10 +476,44 @@ class DigestScheduler:
})
cves += sum(1 for f in findings if f.get("kind") == "cve")
# Patch attempts for this project in the same window.
patch_rows = self.db.list_patch_attempts_in_window(
window_start=window_start,
window_end=window_end,
project_name=prow["name"],
statuses=("pushed", "pr_opened"),
)
patch_entries: list[dict] = []
for pa in patch_rows:
patch_entries.append({
"branch_name": pa.get("branch_name"),
"pr_url": pa.get("pr_url"),
"status": pa.get("status"),
})
# Count open follow-ups via Gitea state check (when configured).
if pa.get("status") == "pr_opened" and pa.get("pr_url"):
if self.gitea_pr_state_check is not None:
owner_repo_n = _parse_pr_url(pa["pr_url"])
if owner_repo_n is not None:
owner, repo, n = owner_repo_n
try:
state = self.gitea_pr_state_check(owner, repo, n)
except Exception as e:
log.warning(
"digest: gitea PR state check failed: %s", e
)
state = None
if state in (None, "open"):
open_followups_total += 1
else:
# Without a checker, treat all pr_opened rows as still open.
open_followups_total += 1
section = {
"project": prow["name"],
"runs": section_runs,
"cves": cves,
"patches": patch_entries,
}
meta = {
@ -446,7 +535,7 @@ class DigestScheduler:
meta["skipped_reason"] = "no_recipients"
per_project_meta.append(meta)
continue
if not section_runs and not wants_summary:
if not section_runs and not patch_entries and not wants_summary:
meta["skipped_reason"] = "zero_activity"
per_project_meta.append(meta)
continue
@ -454,8 +543,18 @@ class DigestScheduler:
per_project_sections.append(section)
per_project_meta.append(meta)
text_body = _render_text(date_str, per_project_sections, full_log_url)
html_body = _render_html(date_str, per_project_sections, full_log_url)
text_body = _render_text(
date_str,
per_project_sections,
full_log_url,
open_followups=open_followups_total,
)
html_body = _render_html(
date_str,
per_project_sections,
full_log_url,
open_followups=open_followups_total,
)
# Per-project send loop. Idempotency check via digest_runs UNIQUE.
for meta, section in zip(
@ -470,11 +569,22 @@ class DigestScheduler:
continue
# Build a per-project-scoped body.
proj_text = _render_text(date_str, [section], full_log_url)
proj_html = _render_html(date_str, [section], full_log_url)
proj_text = _render_text(
date_str,
[section],
full_log_url,
open_followups=open_followups_total,
)
proj_html = _render_html(
date_str,
[section],
full_log_url,
open_followups=open_followups_total,
)
n_patches = len(section.get("patches", []))
subject = (
f"crafting-table digest — {date_str} "
f"({len(section['runs'])} runs, 0 patches drafted, {section['cves']} CVEs)"
f"({len(section['runs'])} runs, {n_patches} patches drafted, {section['cves']} CVEs)"
)
if dry_run or self.smtp is None: