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,191 @@
|
|
|
1
|
+
"""Loop management functionality."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import aiofiles
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoopManager:
|
|
11
|
+
"""Manages loop creation, execution, and configuration."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, loop_dir: Path):
|
|
14
|
+
"""Initialize the loop manager."""
|
|
15
|
+
self.loop_dir = loop_dir
|
|
16
|
+
self.loops_file = loop_dir / "loops.json"
|
|
17
|
+
|
|
18
|
+
async def _load_loops(self) -> dict:
|
|
19
|
+
"""Load loops configuration."""
|
|
20
|
+
if not self.loops_file.exists():
|
|
21
|
+
return {}
|
|
22
|
+
async with aiofiles.open(self.loops_file, 'r') as f:
|
|
23
|
+
content = await f.read()
|
|
24
|
+
return json.loads(content) if content else {}
|
|
25
|
+
|
|
26
|
+
async def _save_loops(self, loops: dict):
|
|
27
|
+
"""Save loops configuration."""
|
|
28
|
+
async with aiofiles.open(self.loops_file, 'w') as f:
|
|
29
|
+
await f.write(json.dumps(loops, indent=2))
|
|
30
|
+
|
|
31
|
+
async def create_loop(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
description: str,
|
|
35
|
+
schedule: str,
|
|
36
|
+
skill_instructions: str,
|
|
37
|
+
goal: str,
|
|
38
|
+
verification_command: Optional[str] = None
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Create a new loop."""
|
|
41
|
+
loops = await self._load_loops()
|
|
42
|
+
|
|
43
|
+
if name in loops:
|
|
44
|
+
return f"❌ Loop '{name}' already exists. Use a different name or delete the existing loop first."
|
|
45
|
+
|
|
46
|
+
loop_config = {
|
|
47
|
+
"name": name,
|
|
48
|
+
"description": description,
|
|
49
|
+
"schedule": schedule,
|
|
50
|
+
"skill_instructions": skill_instructions,
|
|
51
|
+
"verification_command": verification_command or "echo 'No verification configured'",
|
|
52
|
+
"goal": goal,
|
|
53
|
+
"status": "stopped",
|
|
54
|
+
"created_at": datetime.now().isoformat(),
|
|
55
|
+
"last_run": None
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
loops[name] = loop_config
|
|
59
|
+
await self._save_loops(loops)
|
|
60
|
+
|
|
61
|
+
return f"""✅ Loop '{name}' created successfully!
|
|
62
|
+
|
|
63
|
+
**Configuration:**
|
|
64
|
+
- Schedule: {schedule}
|
|
65
|
+
- Goal: {goal}
|
|
66
|
+
- Verification: {verification_command or 'None configured'}
|
|
67
|
+
- Status: Stopped (use start_loop to activate)
|
|
68
|
+
|
|
69
|
+
**Files created:**
|
|
70
|
+
- Configuration: .loop/loops.json
|
|
71
|
+
- Skill: .loop/skills/{name}.md
|
|
72
|
+
- State: .loop/state/{name}.json
|
|
73
|
+
|
|
74
|
+
**Next steps:**
|
|
75
|
+
1. Review the skill file at .loop/skills/{name}.md
|
|
76
|
+
2. Start the loop with: start_loop("{name}")
|
|
77
|
+
3. Monitor with: view_state("{name}")"""
|
|
78
|
+
|
|
79
|
+
async def start_loop(self, name: str) -> str:
|
|
80
|
+
"""Start a loop."""
|
|
81
|
+
loops = await self._load_loops()
|
|
82
|
+
|
|
83
|
+
if name not in loops:
|
|
84
|
+
return f"❌ Loop '{name}' not found. Create it first with create_loop."
|
|
85
|
+
|
|
86
|
+
loops[name]["status"] = "active"
|
|
87
|
+
loops[name]["started_at"] = datetime.now().isoformat()
|
|
88
|
+
await self._save_loops(loops)
|
|
89
|
+
|
|
90
|
+
schedule = loops[name]["schedule"]
|
|
91
|
+
return f"""✅ Loop '{name}' started!
|
|
92
|
+
|
|
93
|
+
**Status:** Active
|
|
94
|
+
**Schedule:** {schedule}
|
|
95
|
+
**Next run:** Based on schedule
|
|
96
|
+
|
|
97
|
+
The loop will now run automatically according to its schedule.
|
|
98
|
+
Monitor progress with: view_state("{name}")"""
|
|
99
|
+
|
|
100
|
+
async def stop_loop(self, name: str) -> str:
|
|
101
|
+
"""Stop a loop."""
|
|
102
|
+
loops = await self._load_loops()
|
|
103
|
+
|
|
104
|
+
if name not in loops:
|
|
105
|
+
return f"❌ Loop '{name}' not found."
|
|
106
|
+
|
|
107
|
+
loops[name]["status"] = "stopped"
|
|
108
|
+
loops[name]["stopped_at"] = datetime.now().isoformat()
|
|
109
|
+
await self._save_loops(loops)
|
|
110
|
+
|
|
111
|
+
return f"""✅ Loop '{name}' stopped.
|
|
112
|
+
|
|
113
|
+
**Status:** Stopped
|
|
114
|
+
**Last run:** {loops[name].get('last_run', 'Never')}
|
|
115
|
+
|
|
116
|
+
The loop is paused but not deleted.
|
|
117
|
+
Restart with: start_loop("{name}")"""
|
|
118
|
+
|
|
119
|
+
async def list_loops(self) -> str:
|
|
120
|
+
"""List all loops with their status."""
|
|
121
|
+
loops = await self._load_loops()
|
|
122
|
+
|
|
123
|
+
if not loops:
|
|
124
|
+
return """📊 No loops configured yet.
|
|
125
|
+
|
|
126
|
+
Create your first loop with create_loop()
|
|
127
|
+
|
|
128
|
+
**Good first loops:**
|
|
129
|
+
- CI failure triage
|
|
130
|
+
- Dependency updates
|
|
131
|
+
- Lint & format fixes
|
|
132
|
+
- Documentation updates"""
|
|
133
|
+
|
|
134
|
+
output = ["📊 **Loop Status**\n"]
|
|
135
|
+
|
|
136
|
+
active_loops = [l for l in loops.values() if l["status"] == "active"]
|
|
137
|
+
stopped_loops = [l for l in loops.values() if l["status"] == "stopped"]
|
|
138
|
+
|
|
139
|
+
if active_loops:
|
|
140
|
+
output.append("**Active Loops:**")
|
|
141
|
+
for loop in active_loops:
|
|
142
|
+
output.append(f"\n{loop['name']}")
|
|
143
|
+
output.append(f" Description: {loop['description']}")
|
|
144
|
+
output.append(f" Schedule: {loop['schedule']}")
|
|
145
|
+
output.append(f" Last run: {loop.get('last_run', 'Never')}")
|
|
146
|
+
output.append(f" Status: 🟢 Active")
|
|
147
|
+
|
|
148
|
+
if stopped_loops:
|
|
149
|
+
output.append("\n**Stopped Loops:**")
|
|
150
|
+
for loop in stopped_loops:
|
|
151
|
+
output.append(f"\n{loop['name']}")
|
|
152
|
+
output.append(f" Description: {loop['description']}")
|
|
153
|
+
output.append(f" Status: ⏸️ Stopped")
|
|
154
|
+
|
|
155
|
+
return "\n".join(output)
|
|
156
|
+
|
|
157
|
+
async def delete_loop(self, name: str) -> str:
|
|
158
|
+
"""Delete a loop permanently."""
|
|
159
|
+
loops = await self._load_loops()
|
|
160
|
+
|
|
161
|
+
if name not in loops:
|
|
162
|
+
return f"❌ Loop '{name}' not found."
|
|
163
|
+
|
|
164
|
+
del loops[name]
|
|
165
|
+
await self._save_loops(loops)
|
|
166
|
+
|
|
167
|
+
return f"""✅ Loop '{name}' deleted permanently.
|
|
168
|
+
|
|
169
|
+
**Cleaned up:**
|
|
170
|
+
- Loop configuration
|
|
171
|
+
- Associated skill file
|
|
172
|
+
- State history
|
|
173
|
+
|
|
174
|
+
This action cannot be undone."""
|
|
175
|
+
|
|
176
|
+
async def configure_verification(self, loop_name: str, command: str) -> str:
|
|
177
|
+
"""Configure verification command for a loop."""
|
|
178
|
+
loops = await self._load_loops()
|
|
179
|
+
|
|
180
|
+
if loop_name not in loops:
|
|
181
|
+
return f"❌ Loop '{loop_name}' not found."
|
|
182
|
+
|
|
183
|
+
loops[loop_name]["verification_command"] = command
|
|
184
|
+
await self._save_loops(loops)
|
|
185
|
+
|
|
186
|
+
return f"""✅ Verification configured for '{loop_name}'
|
|
187
|
+
|
|
188
|
+
**Command:** {command}
|
|
189
|
+
|
|
190
|
+
This command will run before any PR is opened to verify changes.
|
|
191
|
+
If it fails (non-zero exit code), the PR won't be created."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Retry utilities with exponential backoff."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def retry_async(
|
|
10
|
+
fn: Callable[[], T],
|
|
11
|
+
*,
|
|
12
|
+
max_attempts: int = 3,
|
|
13
|
+
base_delay: float = 1.0,
|
|
14
|
+
max_delay: float = 30.0,
|
|
15
|
+
retryable: Callable[[Exception], bool] | None = None,
|
|
16
|
+
) -> T:
|
|
17
|
+
"""Retry an async callable with exponential backoff."""
|
|
18
|
+
last_error: Exception | None = None
|
|
19
|
+
|
|
20
|
+
for attempt in range(1, max_attempts + 1):
|
|
21
|
+
try:
|
|
22
|
+
result = fn()
|
|
23
|
+
if asyncio.iscoroutine(result):
|
|
24
|
+
return await result
|
|
25
|
+
return result
|
|
26
|
+
except Exception as e:
|
|
27
|
+
last_error = e
|
|
28
|
+
if attempt == max_attempts:
|
|
29
|
+
break
|
|
30
|
+
if retryable and not retryable(e):
|
|
31
|
+
raise
|
|
32
|
+
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
33
|
+
await asyncio.sleep(delay)
|
|
34
|
+
|
|
35
|
+
assert last_error is not None
|
|
36
|
+
raise last_error
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Cron-like scheduler for active loops."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Awaitable, Optional, Any
|
|
7
|
+
|
|
8
|
+
from croniter import croniter
|
|
9
|
+
|
|
10
|
+
from .logger import LoopLogger
|
|
11
|
+
from .loop_manager import LoopManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoopScheduler:
|
|
15
|
+
"""Background worker that runs active loops on their cron schedule."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
loop_manager: LoopManager,
|
|
20
|
+
logger: LoopLogger,
|
|
21
|
+
run_callback: Callable[..., Awaitable[str]],
|
|
22
|
+
poll_interval: int = 60,
|
|
23
|
+
):
|
|
24
|
+
self.loop_manager = loop_manager
|
|
25
|
+
self.logger = logger
|
|
26
|
+
self.run_callback = run_callback
|
|
27
|
+
self.poll_interval = poll_interval
|
|
28
|
+
self._task: Optional[asyncio.Task] = None
|
|
29
|
+
self._running = False
|
|
30
|
+
self._last_run: dict[str, datetime] = {}
|
|
31
|
+
self._in_progress: set[str] = set()
|
|
32
|
+
|
|
33
|
+
def _is_due(self, schedule: str, loop_name: str) -> bool:
|
|
34
|
+
"""Check if a loop is due to run based on its cron schedule."""
|
|
35
|
+
try:
|
|
36
|
+
now = datetime.now()
|
|
37
|
+
cron = croniter(schedule, now)
|
|
38
|
+
prev_run = cron.get_prev(datetime)
|
|
39
|
+
last = self._last_run.get(loop_name)
|
|
40
|
+
if last is None or prev_run > last:
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
except (ValueError, KeyError) as e:
|
|
44
|
+
self.logger.warning("invalid_cron", loop_name, schedule=schedule, error=str(e))
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
async def _tick(self) -> None:
|
|
48
|
+
"""Check all active loops and run any that are due."""
|
|
49
|
+
loops = await self.loop_manager._load_loops()
|
|
50
|
+
for name, config in loops.items():
|
|
51
|
+
if config.get("status") != "active":
|
|
52
|
+
continue
|
|
53
|
+
if name in self._in_progress:
|
|
54
|
+
continue
|
|
55
|
+
if not self._is_due(config.get("schedule", ""), name):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
self._in_progress.add(name)
|
|
59
|
+
try:
|
|
60
|
+
self.logger.info("scheduler_triggered", name, schedule=config.get("schedule"))
|
|
61
|
+
await self.run_callback(name)
|
|
62
|
+
self._last_run[name] = datetime.now()
|
|
63
|
+
except Exception as e:
|
|
64
|
+
self.logger.error("scheduler_run_failed", name, error=str(e))
|
|
65
|
+
finally:
|
|
66
|
+
self._in_progress.discard(name)
|
|
67
|
+
|
|
68
|
+
async def _loop(self) -> None:
|
|
69
|
+
"""Main scheduler loop."""
|
|
70
|
+
self._running = True
|
|
71
|
+
self.logger.info("scheduler_started", poll_interval=self.poll_interval)
|
|
72
|
+
while self._running:
|
|
73
|
+
try:
|
|
74
|
+
await self._tick()
|
|
75
|
+
except Exception as e:
|
|
76
|
+
self.logger.error("scheduler_tick_failed", error=str(e))
|
|
77
|
+
await asyncio.sleep(self.poll_interval)
|
|
78
|
+
|
|
79
|
+
def start(self) -> None:
|
|
80
|
+
"""Start the background scheduler."""
|
|
81
|
+
if self._task is None or self._task.done():
|
|
82
|
+
self._task = asyncio.create_task(self._loop())
|
|
83
|
+
|
|
84
|
+
async def stop(self) -> None:
|
|
85
|
+
"""Stop the background scheduler."""
|
|
86
|
+
self._running = False
|
|
87
|
+
if self._task and not self._task.done():
|
|
88
|
+
self._task.cancel()
|
|
89
|
+
try:
|
|
90
|
+
await self._task
|
|
91
|
+
except asyncio.CancelledError:
|
|
92
|
+
pass
|
|
93
|
+
self.logger.info("scheduler_stopped")
|
|
94
|
+
|
|
95
|
+
async def run_now(self, loop_name: str, **kwargs: Any) -> str:
|
|
96
|
+
"""Manually trigger a loop run immediately."""
|
|
97
|
+
if loop_name in self._in_progress:
|
|
98
|
+
return f"⏳ Loop '{loop_name}' is already running."
|
|
99
|
+
self._in_progress.add(loop_name)
|
|
100
|
+
try:
|
|
101
|
+
result = await self.run_callback(loop_name, **kwargs)
|
|
102
|
+
self._last_run[loop_name] = datetime.now()
|
|
103
|
+
return result
|
|
104
|
+
finally:
|
|
105
|
+
self._in_progress.discard(loop_name)
|