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:
parent
ecb9d76e6d
commit
4eab869df0
17 changed files with 2752 additions and 78 deletions
|
|
@ -48,6 +48,7 @@ from .models import (
|
|||
Project,
|
||||
TokenCreateRequest,
|
||||
)
|
||||
from .patcher import Patcher, PatcherConfig
|
||||
from .runner import Runner
|
||||
from .workspace import WorkspaceManager
|
||||
|
||||
|
|
@ -77,6 +78,45 @@ runner: Runner = Runner(
|
|||
_smtp_cfg: SmtpConfig | None = SmtpConfig.from_env()
|
||||
digest_scheduler: DigestScheduler = DigestScheduler(db=db, smtp=_smtp_cfg)
|
||||
|
||||
# Patcher (wave 3): clawdforge + Gitea creds env-driven; if any required env
|
||||
# var is missing, the patcher stays None and the runner hook short-circuits.
|
||||
_patcher_cfg: PatcherConfig | None = PatcherConfig.from_env()
|
||||
patcher: Patcher | None = (
|
||||
Patcher(db=db, workspace=workspace, config=_patcher_cfg, runner=runner)
|
||||
if _patcher_cfg is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
# Wire the patcher into the runner's post-job hook. The runner already runs
|
||||
# the parser pipeline before this hook fires, so by the time we land here
|
||||
# the findings rows for `job_id` are committed and pickable.
|
||||
async def _maybe_auto_patch_hook(event: dict) -> None:
|
||||
if patcher is None:
|
||||
return
|
||||
if event.get("findings_count", 0) <= 0:
|
||||
return
|
||||
project_row = await db.arun(db.get_project, event["project_name"])
|
||||
if project_row is None:
|
||||
return
|
||||
try:
|
||||
recipe = json.loads(project_row.get("recipe_json") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
notify = recipe.get("notify") or {}
|
||||
if not bool(notify.get("auto_patch")):
|
||||
return
|
||||
job = await db.arun(db.get_job, event["job_id"])
|
||||
if job is None:
|
||||
return
|
||||
try:
|
||||
await patcher.maybe_draft_for_job(job)
|
||||
except Exception as e:
|
||||
log.warning("patcher hook failed for job %s: %s", event["job_id"], e)
|
||||
|
||||
|
||||
runner.add_hook(_maybe_auto_patch_hook)
|
||||
|
||||
|
||||
# ---------- lifespan --------------------------------------------------------
|
||||
|
||||
|
|
@ -473,6 +513,103 @@ async def get_job_findings(
|
|||
return {"ok": True, "findings": findings}
|
||||
|
||||
|
||||
# ---- /patches --------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/jobs/{id}/patches")
|
||||
async def trigger_patch(
|
||||
id: str,
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
body: dict | None = None,
|
||||
):
|
||||
"""Manually trigger a patch attempt against a job.
|
||||
|
||||
body: {"finding_id": int | null}. If finding_id is null/absent we pick
|
||||
the highest-severity actionable finding on the job.
|
||||
|
||||
Returns the resulting PatchAttempt as a dict. 503 if the patcher is
|
||||
not configured (CRAFTING_CLAWDFORGE_URL/TOKEN/GITEA_URL/TOKEN missing).
|
||||
"""
|
||||
tok = auth.require_app(request, authorization)
|
||||
job_row = await db.arun(db.get_job, id)
|
||||
_job_visible(job_row, tok)
|
||||
|
||||
if patcher is None:
|
||||
raise HTTPException(503, "patcher not configured")
|
||||
|
||||
body = body or {}
|
||||
finding_id = body.get("finding_id")
|
||||
if finding_id is not None and not isinstance(finding_id, int):
|
||||
raise HTTPException(400, "finding_id must be an integer or null")
|
||||
|
||||
try:
|
||||
attempt = await patcher.maybe_draft(id, finding_id=finding_id)
|
||||
except Exception as e:
|
||||
log.exception("patch trigger failed: %s", e)
|
||||
raise HTTPException(500, f"patch attempt errored: {type(e).__name__}")
|
||||
|
||||
if attempt is None:
|
||||
return {"ok": True, "attempt": None, "reason": "no_actionable_finding"}
|
||||
return {"ok": True, "attempt": _patch_attempt_to_api(attempt)}
|
||||
|
||||
|
||||
@app.get("/patches")
|
||||
async def list_patches(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
project: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
):
|
||||
tok = auth.require_app(request, authorization)
|
||||
owner = None if tok.is_admin else tok.name
|
||||
rows = await db.arun(
|
||||
db.list_patch_attempts,
|
||||
project_name=project,
|
||||
status=status,
|
||||
owner_token=owner,
|
||||
limit=max(1, min(limit, 500)),
|
||||
)
|
||||
return {"ok": True, "patches": rows}
|
||||
|
||||
|
||||
@app.get("/patches/{id}")
|
||||
async def get_patch(
|
||||
id: int,
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
tok = auth.require_app(request, authorization)
|
||||
row = await db.arun(db.get_patch_attempt, int(id))
|
||||
if row is None:
|
||||
raise HTTPException(404, "patch attempt not found")
|
||||
# Visibility-gate via the underlying project.
|
||||
project_row = await db.arun(db.get_project, row["project_name"])
|
||||
if project_row is None:
|
||||
raise HTTPException(404, "patch attempt not found")
|
||||
if not tok.is_admin and project_row["owner_token"] != tok.name:
|
||||
raise HTTPException(404, "patch attempt not found")
|
||||
return {"ok": True, "patch": row}
|
||||
|
||||
|
||||
def _patch_attempt_to_api(attempt) -> dict:
|
||||
"""Serialize a PatchAttempt dataclass to the wire shape."""
|
||||
return {
|
||||
"id": attempt.id,
|
||||
"finding_id": attempt.finding_id,
|
||||
"job_id": attempt.job_id,
|
||||
"project_name": attempt.project_name,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"branch_name": attempt.branch_name,
|
||||
"pr_url": attempt.pr_url,
|
||||
"diff_excerpt": attempt.diff_excerpt,
|
||||
"session_id": attempt.session_id,
|
||||
"error": attempt.error,
|
||||
}
|
||||
|
||||
|
||||
# ---- /digests --------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue