loop-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loop_engineering_mcp/__init__.py +7 -0
- loop_engineering_mcp/__main__.py +37 -0
- loop_engineering_mcp/github_client.py +130 -0
- loop_engineering_mcp/logger.py +60 -0
- loop_engineering_mcp/loop_executor.py +297 -0
- loop_engineering_mcp/loop_manager.py +191 -0
- loop_engineering_mcp/retry.py +36 -0
- loop_engineering_mcp/scheduler.py +105 -0
- loop_engineering_mcp/server.py +378 -0
- loop_engineering_mcp/skill_manager.py +117 -0
- loop_engineering_mcp/state_manager.py +339 -0
- loop_engineering_mcp/verification_runner.py +97 -0
- loop_engineering_mcp/worker.py +53 -0
- loop_mcp-0.1.0.dist-info/METADATA +103 -0
- loop_mcp-0.1.0.dist-info/RECORD +17 -0
- loop_mcp-0.1.0.dist-info/WHEEL +4 -0
- loop_mcp-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Entry point for the Loop Engineering MCP server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from mcp.server.stdio import stdio_server
|
|
6
|
+
from .server import create_server
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _run_server():
|
|
10
|
+
"""Run the MCP server with stdio transport."""
|
|
11
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
12
|
+
server = create_server(start_scheduler=True)
|
|
13
|
+
scheduler = getattr(server, "_loop_scheduler", None)
|
|
14
|
+
if scheduler:
|
|
15
|
+
scheduler.start()
|
|
16
|
+
try:
|
|
17
|
+
init_options = server.create_initialization_options()
|
|
18
|
+
await server.run(read_stream, write_stream, init_options)
|
|
19
|
+
finally:
|
|
20
|
+
if scheduler:
|
|
21
|
+
await scheduler.stop()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
"""Run the MCP server."""
|
|
26
|
+
try:
|
|
27
|
+
asyncio.run(_run_server())
|
|
28
|
+
except KeyboardInterrupt:
|
|
29
|
+
print("\nShutting down Loop Engineering MCP server...", file=sys.stderr)
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"Error running server: {e}", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""GitHub API integration for PR creation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .retry import retry_async
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PullRequestResult:
|
|
15
|
+
"""Result of creating a pull request."""
|
|
16
|
+
|
|
17
|
+
success: bool
|
|
18
|
+
pr_number: Optional[int] = None
|
|
19
|
+
pr_url: Optional[str] = None
|
|
20
|
+
branch: Optional[str] = None
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitHubClient:
|
|
25
|
+
"""Creates branches and pull requests via the GitHub REST API."""
|
|
26
|
+
|
|
27
|
+
API_BASE = "https://api.github.com"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
token: Optional[str] = None,
|
|
32
|
+
repo: Optional[str] = None,
|
|
33
|
+
default_branch: str = "main",
|
|
34
|
+
):
|
|
35
|
+
self.token = token or os.environ.get("GITHUB_TOKEN")
|
|
36
|
+
self.repo = repo or os.environ.get("GITHUB_REPO") or self._detect_repo()
|
|
37
|
+
self.default_branch = default_branch or os.environ.get("GITHUB_DEFAULT_BRANCH", "main")
|
|
38
|
+
|
|
39
|
+
def _detect_repo(self) -> Optional[str]:
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "remote", "get-url", "origin"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=10,
|
|
46
|
+
)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
return None
|
|
49
|
+
url = result.stdout.strip()
|
|
50
|
+
# Handle git@github.com:owner/repo.git and https://github.com/owner/repo.git
|
|
51
|
+
if "github.com" in url:
|
|
52
|
+
parts = url.rstrip(".git").split("/")
|
|
53
|
+
if len(parts) >= 2:
|
|
54
|
+
return f"{parts[-2]}/{parts[-1]}"
|
|
55
|
+
return None
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def _headers(self) -> dict[str, str]:
|
|
60
|
+
if not self.token:
|
|
61
|
+
raise ValueError("GITHUB_TOKEN environment variable is required")
|
|
62
|
+
return {
|
|
63
|
+
"Authorization": f"Bearer {self.token}",
|
|
64
|
+
"Accept": "application/vnd.github+json",
|
|
65
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async def create_pull_request(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
title: str,
|
|
72
|
+
body: str,
|
|
73
|
+
branch: str,
|
|
74
|
+
base: Optional[str] = None,
|
|
75
|
+
) -> PullRequestResult:
|
|
76
|
+
"""Create a pull request on GitHub."""
|
|
77
|
+
if not self.repo:
|
|
78
|
+
return PullRequestResult(
|
|
79
|
+
success=False,
|
|
80
|
+
error="Could not detect GitHub repo. Set GITHUB_REPO=owner/repo",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
base_branch = base or self.default_branch
|
|
84
|
+
|
|
85
|
+
async def _create() -> PullRequestResult:
|
|
86
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
87
|
+
response = await client.post(
|
|
88
|
+
f"{self.API_BASE}/repos/{self.repo}/pulls",
|
|
89
|
+
headers=self._headers(),
|
|
90
|
+
json={
|
|
91
|
+
"title": title,
|
|
92
|
+
"body": body,
|
|
93
|
+
"head": branch,
|
|
94
|
+
"base": base_branch,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
if response.status_code == 422:
|
|
98
|
+
data = response.json()
|
|
99
|
+
errors = data.get("errors", [])
|
|
100
|
+
msg = errors[0].get("message") if errors else data.get("message", "Validation failed")
|
|
101
|
+
return PullRequestResult(success=False, error=msg)
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
data = response.json()
|
|
104
|
+
return PullRequestResult(
|
|
105
|
+
success=True,
|
|
106
|
+
pr_number=data["number"],
|
|
107
|
+
pr_url=data["html_url"],
|
|
108
|
+
branch=branch,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
return await retry_async(_create, max_attempts=3)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return PullRequestResult(success=False, error=str(e))
|
|
115
|
+
|
|
116
|
+
async def push_branch(self, branch: str, cwd: Optional[str] = None) -> tuple[bool, str]:
|
|
117
|
+
"""Push the current branch to origin."""
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
["git", "push", "-u", "origin", branch],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
timeout=120,
|
|
124
|
+
cwd=cwd,
|
|
125
|
+
)
|
|
126
|
+
if result.returncode != 0:
|
|
127
|
+
return False, result.stderr or result.stdout
|
|
128
|
+
return True, "Branch pushed successfully"
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return False, str(e)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Structured logging for loop engineering."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoopLogger:
|
|
11
|
+
"""Writes structured JSON logs to .loop/logs/."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, logs_dir: Path):
|
|
14
|
+
self.logs_dir = logs_dir
|
|
15
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
self._logger = logging.getLogger("loop_engineering")
|
|
17
|
+
if not self._logger.handlers:
|
|
18
|
+
handler = logging.StreamHandler()
|
|
19
|
+
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
|
20
|
+
self._logger.addHandler(handler)
|
|
21
|
+
self._logger.setLevel(logging.INFO)
|
|
22
|
+
|
|
23
|
+
def _write_json(self, filename: str, entry: dict[str, Any]) -> None:
|
|
24
|
+
log_file = self.logs_dir / filename
|
|
25
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
26
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
27
|
+
|
|
28
|
+
def _entry(
|
|
29
|
+
self,
|
|
30
|
+
level: str,
|
|
31
|
+
event: str,
|
|
32
|
+
loop_name: Optional[str] = None,
|
|
33
|
+
**extra: Any,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
37
|
+
"level": level,
|
|
38
|
+
"event": event,
|
|
39
|
+
"loop_name": loop_name,
|
|
40
|
+
**extra,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def info(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
|
|
44
|
+
entry = self._entry("INFO", event, loop_name, **extra)
|
|
45
|
+
self._write_json("loop-engineering.log", entry)
|
|
46
|
+
self._logger.info(f"[{loop_name or 'system'}] {event}")
|
|
47
|
+
|
|
48
|
+
def warning(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
|
|
49
|
+
entry = self._entry("WARNING", event, loop_name, **extra)
|
|
50
|
+
self._write_json("loop-engineering.log", entry)
|
|
51
|
+
self._logger.warning(f"[{loop_name or 'system'}] {event}")
|
|
52
|
+
|
|
53
|
+
def error(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
|
|
54
|
+
entry = self._entry("ERROR", event, loop_name, **extra)
|
|
55
|
+
self._write_json("loop-engineering.log", entry)
|
|
56
|
+
self._logger.error(f"[{loop_name or 'system'}] {event}")
|
|
57
|
+
|
|
58
|
+
def run_log(self, loop_name: str, run_id: str, data: dict[str, Any]) -> None:
|
|
59
|
+
entry = self._entry("INFO", "loop_run", loop_name, run_id=run_id, **data)
|
|
60
|
+
self._write_json(f"{loop_name}-runs.log", entry)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Orchestrates loop runs via the host agent (Cursor/Kiro) — no external AI API calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import aiofiles
|
|
11
|
+
|
|
12
|
+
from .github_client import GitHubClient
|
|
13
|
+
from .logger import LoopLogger
|
|
14
|
+
from .loop_manager import LoopManager
|
|
15
|
+
from .skill_manager import SkillManager
|
|
16
|
+
from .state_manager import StateManager
|
|
17
|
+
from .verification_runner import VerificationRunner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoopExecutor:
|
|
21
|
+
"""Prepares loop runs for the host agent and finalizes them after execution."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
workspace_root: Path,
|
|
26
|
+
loop_manager: LoopManager,
|
|
27
|
+
skill_manager: SkillManager,
|
|
28
|
+
state_manager: StateManager,
|
|
29
|
+
logger: LoopLogger,
|
|
30
|
+
):
|
|
31
|
+
self.workspace_root = workspace_root
|
|
32
|
+
self.loop_manager = loop_manager
|
|
33
|
+
self.skill_manager = skill_manager
|
|
34
|
+
self.state_manager = state_manager
|
|
35
|
+
self.logger = logger
|
|
36
|
+
self.verification_runner = VerificationRunner(workspace_root)
|
|
37
|
+
self.github_client = GitHubClient()
|
|
38
|
+
|
|
39
|
+
async def _read_skill(self, loop_name: str) -> str:
|
|
40
|
+
skill_file = self.skill_manager.skills_dir / f"{loop_name}.md"
|
|
41
|
+
if skill_file.exists():
|
|
42
|
+
async with aiofiles.open(skill_file, "r") as f:
|
|
43
|
+
return await f.read()
|
|
44
|
+
loops = await self.loop_manager._load_loops()
|
|
45
|
+
return loops.get(loop_name, {}).get("skill_instructions", "")
|
|
46
|
+
|
|
47
|
+
async def _build_brief(self, loop_name: str, run_id: str, branch: Optional[str]) -> str:
|
|
48
|
+
loops = await self.loop_manager._load_loops()
|
|
49
|
+
loop_config = loops[loop_name]
|
|
50
|
+
state = await self.state_manager._load_state(loop_name)
|
|
51
|
+
skill_content = await self._read_skill(loop_name)
|
|
52
|
+
lessons = state.get("lessons_learned", [])
|
|
53
|
+
lessons_text = "\n".join(f"- {l}" for l in lessons[-10:]) if lessons else "None yet."
|
|
54
|
+
|
|
55
|
+
branch_line = f"**Branch:** `{branch}`\n" if branch else ""
|
|
56
|
+
return f"""🔄 **Loop run started: {loop_name}** (run_id: `{run_id}`)
|
|
57
|
+
|
|
58
|
+
You (the host agent in Cursor/Kiro) execute this loop — no external API keys needed.
|
|
59
|
+
Make the code changes in the workspace, then call `complete_loop_run` with this run_id.
|
|
60
|
+
|
|
61
|
+
{branch_line}**Goal:** {loop_config['goal']}
|
|
62
|
+
|
|
63
|
+
**Verification command:** `{loop_config.get('verification_command', 'none')}`
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Skill Instructions
|
|
68
|
+
|
|
69
|
+
{skill_content}
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Lessons Learned (from previous runs)
|
|
74
|
+
|
|
75
|
+
{lessons_text}
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## When done
|
|
80
|
+
|
|
81
|
+
1. Make all required code changes in the workspace
|
|
82
|
+
2. Call `complete_loop_run` with:
|
|
83
|
+
- `loop_name`: "{loop_name}"
|
|
84
|
+
- `run_id`: "{run_id}"
|
|
85
|
+
- `summary`: brief description of what you changed
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def _has_git_changes(self) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
["git", "status", "--porcelain"],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=30,
|
|
95
|
+
cwd=str(self.workspace_root),
|
|
96
|
+
)
|
|
97
|
+
return bool(result.stdout.strip())
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def _create_branch(self, loop_name: str) -> tuple[bool, str]:
|
|
102
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
103
|
+
branch = f"loop/{loop_name}-{timestamp}"
|
|
104
|
+
try:
|
|
105
|
+
subprocess.run(
|
|
106
|
+
["git", "checkout", "-b", branch],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
timeout=30,
|
|
110
|
+
cwd=str(self.workspace_root),
|
|
111
|
+
check=True,
|
|
112
|
+
)
|
|
113
|
+
return True, branch
|
|
114
|
+
except subprocess.CalledProcessError as e:
|
|
115
|
+
return False, e.stderr or str(e)
|
|
116
|
+
|
|
117
|
+
async def begin_run(self, loop_name: str, *, create_pr: bool = True) -> str:
|
|
118
|
+
"""Prepare a loop run and return instructions for the host agent to execute."""
|
|
119
|
+
run_id = str(uuid.uuid4())[:8]
|
|
120
|
+
self.logger.info("loop_run_started", loop_name, run_id=run_id)
|
|
121
|
+
|
|
122
|
+
loops = await self.loop_manager._load_loops()
|
|
123
|
+
if loop_name not in loops:
|
|
124
|
+
return f"❌ Loop '{loop_name}' not found."
|
|
125
|
+
|
|
126
|
+
state = await self.state_manager._load_state(loop_name)
|
|
127
|
+
if state.get("active_run"):
|
|
128
|
+
active = state["active_run"]
|
|
129
|
+
return (
|
|
130
|
+
f"⏳ Loop '{loop_name}' already has an active run "
|
|
131
|
+
f"(run_id: `{active.get('run_id')}`). "
|
|
132
|
+
f"Complete it with `complete_loop_run` first."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
branch = None
|
|
136
|
+
if create_pr:
|
|
137
|
+
ok, branch_or_err = self._create_branch(loop_name)
|
|
138
|
+
if ok:
|
|
139
|
+
branch = branch_or_err
|
|
140
|
+
else:
|
|
141
|
+
self.logger.warning("branch_creation_failed", loop_name, error=branch_or_err)
|
|
142
|
+
|
|
143
|
+
await self.state_manager.set_active_run(
|
|
144
|
+
loop_name, run_id=run_id, branch=branch, create_pr=create_pr
|
|
145
|
+
)
|
|
146
|
+
await self.state_manager.remove_pending_run(loop_name)
|
|
147
|
+
|
|
148
|
+
brief = await self._build_brief(loop_name, run_id, branch)
|
|
149
|
+
self.logger.info("loop_brief_ready", loop_name, run_id=run_id)
|
|
150
|
+
return brief
|
|
151
|
+
|
|
152
|
+
async def queue_run(self, loop_name: str) -> str:
|
|
153
|
+
"""Queue a loop for the next host agent session (used by scheduler)."""
|
|
154
|
+
loops = await self.loop_manager._load_loops()
|
|
155
|
+
if loop_name not in loops:
|
|
156
|
+
return f"❌ Loop '{loop_name}' not found."
|
|
157
|
+
|
|
158
|
+
state = await self.state_manager._load_state(loop_name)
|
|
159
|
+
if state.get("active_run"):
|
|
160
|
+
return f"⏳ Loop '{loop_name}' already has an active run — skipping queue."
|
|
161
|
+
|
|
162
|
+
run_id = str(uuid.uuid4())[:8]
|
|
163
|
+
await self.state_manager.queue_pending_run(loop_name, run_id, source="schedule")
|
|
164
|
+
self.logger.info("loop_queued", loop_name, run_id=run_id)
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
f"📋 Loop '{loop_name}' queued (run_id: `{run_id}`).\n"
|
|
168
|
+
f"Call `list_pending_runs` then `run_loop_now` when a host agent session is active."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def complete_run(
|
|
172
|
+
self,
|
|
173
|
+
loop_name: str,
|
|
174
|
+
run_id: str,
|
|
175
|
+
summary: str,
|
|
176
|
+
*,
|
|
177
|
+
create_pr: Optional[bool] = None,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Finalize a loop run after the host agent has made changes."""
|
|
180
|
+
loops = await self.loop_manager._load_loops()
|
|
181
|
+
if loop_name not in loops:
|
|
182
|
+
return f"❌ Loop '{loop_name}' not found."
|
|
183
|
+
|
|
184
|
+
state = await self.state_manager._load_state(loop_name)
|
|
185
|
+
active = state.get("active_run")
|
|
186
|
+
if not active or active.get("run_id") != run_id:
|
|
187
|
+
return f"❌ No active run with run_id `{run_id}` for loop '{loop_name}'."
|
|
188
|
+
|
|
189
|
+
loop_config = loops[loop_name]
|
|
190
|
+
should_create_pr = create_pr if create_pr is not None else active.get("create_pr", True)
|
|
191
|
+
branch = active.get("branch")
|
|
192
|
+
|
|
193
|
+
verification_passed = False
|
|
194
|
+
pr_result = None
|
|
195
|
+
status = "failed"
|
|
196
|
+
error_msg: Optional[str] = None
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
verification_cmd = loop_config.get("verification_command", "")
|
|
200
|
+
self.logger.info("verification_started", loop_name, command=verification_cmd)
|
|
201
|
+
verification = await self.verification_runner.run(verification_cmd)
|
|
202
|
+
verification_passed = verification.success
|
|
203
|
+
|
|
204
|
+
if not verification_passed:
|
|
205
|
+
status = "verification_failed"
|
|
206
|
+
error_msg = verification.stderr or f"Exit code {verification.exit_code}"
|
|
207
|
+
await self.state_manager.record_escalation(
|
|
208
|
+
loop_name, f"Verification failed: {error_msg}"
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
has_changes = self._has_git_changes()
|
|
212
|
+
if should_create_pr and has_changes and branch:
|
|
213
|
+
subprocess.run(
|
|
214
|
+
["git", "add", "-A"],
|
|
215
|
+
cwd=str(self.workspace_root),
|
|
216
|
+
capture_output=True,
|
|
217
|
+
timeout=30,
|
|
218
|
+
)
|
|
219
|
+
subprocess.run(
|
|
220
|
+
["git", "commit", "-m", f"loop({loop_name}): automated changes"],
|
|
221
|
+
cwd=str(self.workspace_root),
|
|
222
|
+
capture_output=True,
|
|
223
|
+
timeout=30,
|
|
224
|
+
)
|
|
225
|
+
push_ok, push_msg = await self.github_client.push_branch(
|
|
226
|
+
branch, cwd=str(self.workspace_root)
|
|
227
|
+
)
|
|
228
|
+
if push_ok:
|
|
229
|
+
pr_result = await self.github_client.create_pull_request(
|
|
230
|
+
title=f"[loop] {loop_name}: {loop_config['description'][:60]}",
|
|
231
|
+
body=(
|
|
232
|
+
f"Automated loop run.\n\n**Goal:** {loop_config['goal']}\n\n"
|
|
233
|
+
f"**Summary:**\n{summary[:2000]}"
|
|
234
|
+
),
|
|
235
|
+
branch=branch,
|
|
236
|
+
)
|
|
237
|
+
status = "success" if pr_result.success else "pr_failed"
|
|
238
|
+
if not pr_result.success:
|
|
239
|
+
error_msg = pr_result.error
|
|
240
|
+
else:
|
|
241
|
+
status = "push_failed"
|
|
242
|
+
error_msg = push_msg
|
|
243
|
+
elif has_changes:
|
|
244
|
+
status = "success"
|
|
245
|
+
else:
|
|
246
|
+
status = "success_no_changes"
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
status = "error"
|
|
250
|
+
error_msg = str(e)
|
|
251
|
+
self.logger.error("loop_run_failed", loop_name, run_id=run_id, error=error_msg)
|
|
252
|
+
await self.state_manager.record_escalation(loop_name, f"Run error: {error_msg}")
|
|
253
|
+
|
|
254
|
+
final_summary = summary
|
|
255
|
+
if error_msg:
|
|
256
|
+
final_summary = f"{summary} ({status}: {error_msg})"
|
|
257
|
+
|
|
258
|
+
await self.state_manager.record_run(
|
|
259
|
+
loop_name,
|
|
260
|
+
summary=final_summary,
|
|
261
|
+
status=status,
|
|
262
|
+
verification_passed=verification_passed,
|
|
263
|
+
pr_url=pr_result.pr_url if pr_result and pr_result.success else None,
|
|
264
|
+
)
|
|
265
|
+
await self.state_manager.clear_active_run(loop_name)
|
|
266
|
+
|
|
267
|
+
loops = await self.loop_manager._load_loops()
|
|
268
|
+
if loop_name in loops:
|
|
269
|
+
loops[loop_name]["last_run"] = datetime.now().isoformat()
|
|
270
|
+
await self.loop_manager._save_loops(loops)
|
|
271
|
+
|
|
272
|
+
self.logger.run_log(
|
|
273
|
+
loop_name,
|
|
274
|
+
run_id,
|
|
275
|
+
{
|
|
276
|
+
"status": status,
|
|
277
|
+
"verification_passed": verification_passed,
|
|
278
|
+
"pr_url": pr_result.pr_url if pr_result else None,
|
|
279
|
+
"error": error_msg,
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
output = [
|
|
284
|
+
f"✅ **Loop run complete: {loop_name}** (run_id: {run_id})",
|
|
285
|
+
f"**Status:** {status}",
|
|
286
|
+
f"**Verification:** {'✅ Passed' if verification_passed else '❌ Failed'}",
|
|
287
|
+
]
|
|
288
|
+
if pr_result and pr_result.success:
|
|
289
|
+
output.append(f"**PR:** {pr_result.pr_url}")
|
|
290
|
+
if error_msg:
|
|
291
|
+
output.append(f"**Error:** {error_msg}")
|
|
292
|
+
|
|
293
|
+
return "\n".join(output)
|
|
294
|
+
|
|
295
|
+
# Backward-compatible alias: begin_run (not full autonomous execution)
|
|
296
|
+
async def run(self, loop_name: str, *, create_pr: bool = True) -> str:
|
|
297
|
+
return await self.begin_run(loop_name, create_pr=create_pr)
|