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

@ -572,43 +572,99 @@ class TestGetFindings(unittest.TestCase):
self.assertIn("not found", content[0].text)
class TestDraftPatchStub(unittest.TestCase):
"""Wave 2B stub: tool surface present, but returns a 'pending' message."""
class TestDraftPatch(unittest.TestCase):
"""Wave 3: real call to POST /jobs/{id}/patches; two-block return."""
def test_returns_pending_message(self) -> None:
@responses.activate
def test_pr_opened_two_block_return(self) -> None:
"""Server returns a pr_opened attempt → MCP returns prose + JSON."""
responses.add(
responses.POST,
f"{BASE_URL}/jobs/j-1/patches",
json={
"ok": True,
"attempt": {
"id": 7,
"finding_id": 42,
"job_id": "j-1",
"project_name": "demo",
"attempt_number": 1,
"status": "pr_opened",
"branch_name": "crafting-table/auto/j-1-42",
"pr_url": "http://192.168.0.5:3001/X/Y/pulls/9",
"diff_excerpt": "--- a/x\n+++ b/x",
"session_id": "s-1",
"error": None,
},
},
status=200,
)
c = _client()
try:
content, is_error = _run(
_dispatch(c, TOOL_DRAFT_PATCH, {"job_id": "j-1"})
)
finally:
c.close()
self.assertFalse(is_error)
# Two-content-block return: prose + JSON.
self.assertEqual(len(content), 2)
prose = content[0].text
self.assertIn("pr_opened", prose)
self.assertIn("crafting-table/auto/j-1-42", prose)
self.assertIn("/pulls/9", prose)
body = json.loads(content[1].text)
self.assertTrue(body["ok"])
self.assertEqual(body["attempt"]["status"], "pr_opened")
@responses.activate
def test_no_actionable_finding(self) -> None:
responses.add(
responses.POST,
f"{BASE_URL}/jobs/j-1/patches",
json={"ok": True, "attempt": None, "reason": "no_actionable_finding"},
status=200,
)
c = _client()
try:
content, is_error = _run(
_dispatch(c, TOOL_DRAFT_PATCH, {"job_id": "j-1"})
)
finally:
c.close()
self.assertFalse(is_error)
self.assertIn("no actionable finding", content[0].text)
@responses.activate
def test_with_finding_id_passes_through(self) -> None:
responses.add(
responses.POST,
f"{BASE_URL}/jobs/j-1/patches",
json={
"ok": True,
"attempt": {
"id": 1, "finding_id": 42, "job_id": "j-1",
"project_name": "demo", "attempt_number": 1,
"status": "drafted", "branch_name": None, "pr_url": None,
"diff_excerpt": None, "session_id": None,
"error": "malformed_response",
},
},
status=200,
)
c = _client()
try:
content, is_error = _run(
_dispatch(
c,
TOOL_DRAFT_PATCH,
{"job_id": "j-1"},
c, TOOL_DRAFT_PATCH, {"job_id": "j-1", "finding_id": 42}
)
)
finally:
c.close()
self.assertFalse(is_error)
body = json.loads(content[0].text)
self.assertFalse(body["ok"])
self.assertTrue(body["pending"])
self.assertIn("not yet implemented", body["message"])
self.assertIn("wave 3", body["message"])
def test_with_finding_id(self) -> None:
c = _client()
try:
content, is_error = _run(
_dispatch(
c,
TOOL_DRAFT_PATCH,
{"job_id": "j-1", "finding_id": 42},
)
)
finally:
c.close()
self.assertFalse(is_error)
body = json.loads(content[0].text)
self.assertEqual(body["finding_id"], 42)
body = json.loads(content[1].text)
self.assertEqual(body["attempt"]["finding_id"], 42)
self.assertEqual(body["attempt"]["status"], "drafted")
def test_rejects_bool_finding_id(self) -> None:
# bool is a subclass of int — defense-in-depth.
@ -616,9 +672,7 @@ class TestDraftPatchStub(unittest.TestCase):
try:
content, is_error = _run(
_dispatch(
c,
TOOL_DRAFT_PATCH,
{"job_id": "j-1", "finding_id": True},
c, TOOL_DRAFT_PATCH, {"job_id": "j-1", "finding_id": True}
)
)
finally:
@ -635,6 +689,24 @@ class TestDraftPatchStub(unittest.TestCase):
self.assertTrue(is_error)
self.assertIn("job_id", content[0].text)
@responses.activate
def test_503_when_patcher_disabled(self) -> None:
responses.add(
responses.POST,
f"{BASE_URL}/jobs/j-1/patches",
json={"detail": "patcher not configured"},
status=503,
)
c = _client()
try:
content, is_error = _run(
_dispatch(c, TOOL_DRAFT_PATCH, {"job_id": "j-1"})
)
finally:
c.close()
self.assertTrue(is_error)
self.assertIn("503", content[0].text)
class TestUnknownTool(unittest.TestCase):
def test_unknown_tool_returns_error(self) -> None: