claude-evolve 1.8.51 → 1.9.1

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.
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main orchestrator for claude-evolve.
4
+ Manages worker processes and coordinates ideation.
5
+
6
+ AIDEV-NOTE: This is the Python port of bin/claude-evolve-run.
7
+ Exit codes:
8
+ 0 - Success (evolution complete)
9
+ 1 - Error
10
+ 2 - Rate limit (workers should retry later)
11
+ 3 - API exhausted
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import List, Optional, Set
23
+
24
+ # Add lib to path
25
+ SCRIPT_DIR = Path(__file__).parent
26
+ sys.path.insert(0, str(SCRIPT_DIR.parent))
27
+
28
+ from lib.evolution_csv import EvolutionCSV
29
+
30
+
31
+ @dataclass
32
+ class RunConfig:
33
+ """Configuration for the run orchestrator."""
34
+ csv_path: str
35
+ evolution_dir: str
36
+ max_workers: int = 4
37
+ auto_ideate: bool = True
38
+ worker_timeout: int = 600
39
+ poll_interval: int = 5
40
+ min_completed_for_ideation: int = 3
41
+ config_path: Optional[str] = None
42
+
43
+
44
+ class WorkerPool:
45
+ """Manages worker subprocess pool."""
46
+
47
+ def __init__(self, max_workers: int, worker_script: Path, config_path: Optional[str], timeout: int):
48
+ self.max_workers = max_workers
49
+ self.worker_script = worker_script
50
+ self.config_path = config_path
51
+ self.timeout = timeout
52
+ self.workers: dict[int, subprocess.Popen] = {} # pid -> process
53
+
54
+ def spawn_worker(self) -> Optional[int]:
55
+ """Spawn a new worker. Returns pid or None if at capacity."""
56
+ if len(self.workers) >= self.max_workers:
57
+ return None
58
+
59
+ cmd = [sys.executable, str(self.worker_script)]
60
+ if self.config_path:
61
+ cmd.extend(['--config', self.config_path])
62
+ if self.timeout:
63
+ cmd.extend(['--timeout', str(self.timeout)])
64
+
65
+ try:
66
+ proc = subprocess.Popen(
67
+ cmd,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.STDOUT,
70
+ text=True
71
+ )
72
+ self.workers[proc.pid] = proc
73
+ print(f"[RUN] Spawned worker {proc.pid}", file=sys.stderr)
74
+ return proc.pid
75
+ except Exception as e:
76
+ print(f"[RUN] Failed to spawn worker: {e}", file=sys.stderr)
77
+ return None
78
+
79
+ def cleanup_finished(self) -> List[int]:
80
+ """Clean up finished workers. Returns list of exit codes."""
81
+ exit_codes = []
82
+ finished_pids = []
83
+
84
+ for pid, proc in list(self.workers.items()):
85
+ ret = proc.poll()
86
+ if ret is not None:
87
+ finished_pids.append(pid)
88
+ exit_codes.append(ret)
89
+
90
+ # Log output
91
+ if proc.stdout:
92
+ output = proc.stdout.read()
93
+ if output:
94
+ for line in output.strip().split('\n'):
95
+ print(f"[WORKER-{pid}] {line}", file=sys.stderr)
96
+
97
+ print(f"[RUN] Worker {pid} exited with code {ret}", file=sys.stderr)
98
+
99
+ for pid in finished_pids:
100
+ del self.workers[pid]
101
+
102
+ return exit_codes
103
+
104
+ def shutdown(self, timeout: int = 10):
105
+ """Shutdown all workers gracefully."""
106
+ if not self.workers:
107
+ return
108
+
109
+ print(f"[RUN] Shutting down {len(self.workers)} workers...", file=sys.stderr)
110
+
111
+ # Send SIGTERM
112
+ for pid, proc in self.workers.items():
113
+ try:
114
+ proc.terminate()
115
+ except Exception:
116
+ pass
117
+
118
+ # Wait for graceful shutdown
119
+ deadline = time.time() + timeout
120
+ while self.workers and time.time() < deadline:
121
+ self.cleanup_finished()
122
+ if self.workers:
123
+ time.sleep(0.5)
124
+
125
+ # Force kill remaining
126
+ for pid, proc in list(self.workers.items()):
127
+ try:
128
+ proc.kill()
129
+ print(f"[RUN] Force killed worker {pid}", file=sys.stderr)
130
+ except Exception:
131
+ pass
132
+
133
+ self.workers.clear()
134
+
135
+ @property
136
+ def active_count(self) -> int:
137
+ return len(self.workers)
138
+
139
+
140
+ class EvolutionRunner:
141
+ """Main evolution orchestrator."""
142
+
143
+ def __init__(self, config: RunConfig):
144
+ self.config = config
145
+ self.worker_script = SCRIPT_DIR / "evolve_worker.py"
146
+ self.ideate_script = SCRIPT_DIR / "evolve_ideate.py"
147
+ self.pool = WorkerPool(
148
+ max_workers=config.max_workers,
149
+ worker_script=self.worker_script,
150
+ config_path=config.config_path,
151
+ timeout=config.worker_timeout
152
+ )
153
+ self.api_limit_reached = False
154
+ self.shutdown_requested = False
155
+ self._setup_signal_handlers()
156
+
157
+ def _setup_signal_handlers(self):
158
+ """Setup signal handlers for graceful shutdown."""
159
+ signal.signal(signal.SIGTERM, self._handle_signal)
160
+ signal.signal(signal.SIGINT, self._handle_signal)
161
+
162
+ def _handle_signal(self, signum, frame):
163
+ """Handle termination signal."""
164
+ sig_name = signal.Signals(signum).name
165
+ print(f"\n[RUN] Received {sig_name}, shutting down...", file=sys.stderr)
166
+ self.shutdown_requested = True
167
+ self.pool.shutdown()
168
+ sys.exit(128 + signum)
169
+
170
+ def cleanup_csv(self):
171
+ """Clean up CSV at startup."""
172
+ print("[RUN] Cleaning up CSV...", file=sys.stderr)
173
+ with EvolutionCSV(self.config.csv_path) as csv:
174
+ # Remove duplicates
175
+ removed = csv.remove_duplicate_candidates()
176
+ if removed:
177
+ print(f"[RUN] Removed {removed} duplicate candidates", file=sys.stderr)
178
+
179
+ # Reset stuck candidates
180
+ reset = csv.reset_stuck_candidates()
181
+ if reset:
182
+ print(f"[RUN] Reset {reset} stuck candidates", file=sys.stderr)
183
+
184
+ # Clean corrupted status fields
185
+ fixed = csv.cleanup_corrupted_status_fields()
186
+ if fixed:
187
+ print(f"[RUN] Fixed {fixed} corrupted status fields", file=sys.stderr)
188
+
189
+ def ensure_baseline(self):
190
+ """Ensure baseline entry exists in CSV."""
191
+ with EvolutionCSV(self.config.csv_path) as csv:
192
+ info = csv.get_candidate_info('baseline-000')
193
+ if not info:
194
+ print("[RUN] Adding baseline-000 entry", file=sys.stderr)
195
+ csv.append_candidates([{
196
+ 'id': 'baseline-000',
197
+ 'basedOnId': '',
198
+ 'description': 'Original algorithm.py performance',
199
+ 'status': 'pending'
200
+ }])
201
+
202
+ def get_stats(self) -> dict:
203
+ """Get CSV statistics."""
204
+ with EvolutionCSV(self.config.csv_path) as csv:
205
+ return csv.get_csv_stats()
206
+
207
+ def should_ideate(self, stats: dict) -> bool:
208
+ """Check if we should run ideation."""
209
+ if not self.config.auto_ideate:
210
+ return False
211
+
212
+ # Need minimum completed algorithms to learn from
213
+ if stats['complete'] < self.config.min_completed_for_ideation:
214
+ print(f"[RUN] Not enough completed ({stats['complete']} < {self.config.min_completed_for_ideation})", file=sys.stderr)
215
+ return False
216
+
217
+ return True
218
+
219
+ def run_ideation(self) -> bool:
220
+ """Run ideation. Returns True on success."""
221
+ print("[RUN] Running ideation...", file=sys.stderr)
222
+
223
+ cmd = [sys.executable, str(self.ideate_script)]
224
+ if self.config.config_path:
225
+ cmd.extend(['--config', self.config.config_path])
226
+
227
+ try:
228
+ result = subprocess.run(
229
+ cmd,
230
+ capture_output=True,
231
+ text=True,
232
+ cwd=self.config.evolution_dir
233
+ )
234
+
235
+ # Log output
236
+ if result.stdout:
237
+ for line in result.stdout.strip().split('\n'):
238
+ print(f"[IDEATE] {line}", file=sys.stderr)
239
+ if result.stderr:
240
+ for line in result.stderr.strip().split('\n'):
241
+ print(f"[IDEATE] {line}", file=sys.stderr)
242
+
243
+ return result.returncode == 0
244
+
245
+ except Exception as e:
246
+ print(f"[RUN] Ideation failed: {e}", file=sys.stderr)
247
+ return False
248
+
249
+ def run(self) -> int:
250
+ """
251
+ Main orchestration loop.
252
+
253
+ Returns:
254
+ Exit code
255
+ """
256
+ print("[RUN] Starting evolution run", file=sys.stderr)
257
+ print(f"[RUN] Max workers: {self.config.max_workers}", file=sys.stderr)
258
+ print(f"[RUN] Auto ideate: {self.config.auto_ideate}", file=sys.stderr)
259
+
260
+ # Startup cleanup
261
+ self.cleanup_csv()
262
+ self.ensure_baseline()
263
+
264
+ iteration = 0
265
+
266
+ while not self.shutdown_requested:
267
+ iteration += 1
268
+
269
+ # Clean up finished workers
270
+ exit_codes = self.pool.cleanup_finished()
271
+
272
+ # Check for API limit
273
+ if 2 in exit_codes or 3 in exit_codes:
274
+ print("[RUN] API limit reached, waiting 5 minutes...", file=sys.stderr)
275
+ self.api_limit_reached = True
276
+ time.sleep(300) # 5 minute wait
277
+ self.api_limit_reached = False
278
+ self.cleanup_csv() # Reset stuck candidates
279
+ continue
280
+
281
+ # Periodic cleanup (every 5 iterations)
282
+ if iteration % 5 == 0 and self.pool.active_count == 0:
283
+ with EvolutionCSV(self.config.csv_path) as csv:
284
+ csv.reset_stuck_candidates()
285
+
286
+ # Get stats
287
+ stats = self.get_stats()
288
+ print(f"[RUN] Stats: {stats['pending']} pending, {stats['complete']} complete, {stats['running']} running", file=sys.stderr)
289
+
290
+ # Check if we need ideation
291
+ if stats['pending'] == 0 and self.pool.active_count == 0:
292
+ # First reset any stuck candidates
293
+ with EvolutionCSV(self.config.csv_path) as csv:
294
+ csv.reset_stuck_candidates()
295
+
296
+ # Re-check stats after reset
297
+ stats = self.get_stats()
298
+
299
+ if stats['pending'] == 0:
300
+ if self.should_ideate(stats):
301
+ if self.run_ideation():
302
+ continue # Loop back to check for new work
303
+ else:
304
+ print("[RUN] Ideation failed, waiting...", file=sys.stderr)
305
+ time.sleep(30)
306
+ continue
307
+ else:
308
+ print("[RUN] Evolution complete!", file=sys.stderr)
309
+ break
310
+
311
+ # Spawn workers for pending work
312
+ while stats['pending'] > 0 and self.pool.active_count < self.config.max_workers:
313
+ pid = self.pool.spawn_worker()
314
+ if pid is None:
315
+ break
316
+ stats['pending'] -= 1 # Optimistic decrement
317
+
318
+ # Sleep before next iteration
319
+ time.sleep(self.config.poll_interval)
320
+
321
+ # Cleanup
322
+ self.pool.shutdown()
323
+ print("[RUN] Exiting", file=sys.stderr)
324
+ return 0
325
+
326
+
327
+ def load_config(config_path: Optional[str] = None) -> RunConfig:
328
+ """Load configuration from YAML."""
329
+ import yaml
330
+
331
+ # Find config
332
+ if config_path:
333
+ yaml_path = Path(config_path)
334
+ elif os.environ.get('CLAUDE_EVOLVE_CONFIG'):
335
+ yaml_path = Path(os.environ['CLAUDE_EVOLVE_CONFIG'])
336
+ else:
337
+ yaml_path = Path('evolution/config.yaml')
338
+ if not yaml_path.exists():
339
+ yaml_path = Path('config.yaml')
340
+
341
+ if not yaml_path.exists():
342
+ raise FileNotFoundError(f"Config not found: {yaml_path}")
343
+
344
+ with open(yaml_path) as f:
345
+ data = yaml.safe_load(f) or {}
346
+
347
+ base_dir = yaml_path.parent
348
+
349
+ def resolve(path: str) -> str:
350
+ p = Path(path)
351
+ if not p.is_absolute():
352
+ p = base_dir / p
353
+ return str(p.resolve())
354
+
355
+ parallel = data.get('parallel', {})
356
+
357
+ return RunConfig(
358
+ csv_path=resolve(data.get('csv_file', 'evolution.csv')),
359
+ evolution_dir=str(base_dir.resolve()),
360
+ max_workers=parallel.get('max_workers', 4),
361
+ auto_ideate=data.get('auto_ideate', True),
362
+ worker_timeout=data.get('timeout_seconds', 600),
363
+ poll_interval=parallel.get('poll_interval', 5),
364
+ min_completed_for_ideation=data.get('min_completed_for_ideation', 3),
365
+ config_path=str(yaml_path.resolve())
366
+ )
367
+
368
+
369
+ def main():
370
+ parser = argparse.ArgumentParser(description='Claude Evolve Runner')
371
+ parser.add_argument('--config', help='Path to config.yaml')
372
+ parser.add_argument('--parallel', type=int, help='Max parallel workers')
373
+ parser.add_argument('--sequential', action='store_true', help='Run sequentially (1 worker)')
374
+ parser.add_argument('--timeout', type=int, help='Worker timeout in seconds')
375
+ args = parser.parse_args()
376
+
377
+ try:
378
+ config = load_config(args.config)
379
+
380
+ if args.sequential:
381
+ config.max_workers = 1
382
+ elif args.parallel:
383
+ config.max_workers = args.parallel
384
+
385
+ if args.timeout:
386
+ config.worker_timeout = args.timeout
387
+
388
+ runner = EvolutionRunner(config)
389
+ sys.exit(runner.run())
390
+
391
+ except FileNotFoundError as e:
392
+ print(f"Error: {e}", file=sys.stderr)
393
+ sys.exit(1)
394
+ except Exception as e:
395
+ print(f"Error: {e}", file=sys.stderr)
396
+ import traceback
397
+ traceback.print_exc()
398
+ sys.exit(1)
399
+
400
+
401
+ if __name__ == '__main__':
402
+ main()