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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue