autoforge-ai 0.1.20 → 0.1.22

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.
@@ -15,6 +15,7 @@ from typing import Optional
15
15
 
16
16
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
17
17
  from apscheduler.triggers.cron import CronTrigger
18
+ from apscheduler.triggers.interval import IntervalTrigger
18
19
 
19
20
  # Add parent directory for imports
20
21
  sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -73,19 +74,39 @@ class SchedulerService:
73
74
  logger.info("Scheduler service stopped")
74
75
 
75
76
  async def _load_all_schedules(self):
76
- """Load schedules for all registered projects."""
77
+ """Load schedules and auto-improve jobs for all registered projects."""
77
78
  from registry import list_registered_projects
78
79
 
79
80
  try:
80
81
  projects = list_registered_projects()
81
82
  total_loaded = 0
83
+ total_auto_improve = 0
82
84
  for project_name, info in projects.items():
83
85
  project_path = Path(info.get("path", ""))
84
- if project_path.exists():
85
- count = await self._load_project_schedules(project_name, project_path)
86
- total_loaded += count
86
+ if not project_path.exists():
87
+ continue
88
+
89
+ # Windowed schedules (cron-based)
90
+ count = await self._load_project_schedules(project_name, project_path)
91
+ total_loaded += count
92
+
93
+ # Auto-improve interval jobs (stored in the registry directly)
94
+ if info.get("auto_improve_enabled"):
95
+ interval = int(info.get("auto_improve_interval_minutes", 10) or 10)
96
+ try:
97
+ await self.register_auto_improve(project_name, project_path, interval)
98
+ total_auto_improve += 1
99
+ except Exception as e:
100
+ logger.error(
101
+ f"Failed to register auto-improve for {project_name}: {e}"
102
+ )
103
+
87
104
  if total_loaded > 0:
88
105
  logger.info(f"Loaded {total_loaded} schedule(s) across all projects")
106
+ if total_auto_improve > 0:
107
+ logger.info(
108
+ f"Registered {total_auto_improve} auto-improve job(s) across all projects"
109
+ )
89
110
  except Exception as e:
90
111
  logger.error(f"Error loading schedules: {e}")
91
112
 
@@ -205,6 +226,135 @@ class SchedulerService:
205
226
  else:
206
227
  logger.warning(f"No jobs found to remove for schedule {schedule_id}")
207
228
 
229
+ # ------------------------------------------------------------------
230
+ # Auto-improve interval jobs
231
+ # ------------------------------------------------------------------
232
+
233
+ @staticmethod
234
+ def _auto_improve_job_id(project_name: str) -> str:
235
+ return f"auto_improve_{project_name}"
236
+
237
+ async def register_auto_improve(
238
+ self,
239
+ project_name: str,
240
+ project_dir: Path,
241
+ interval_minutes: int,
242
+ ):
243
+ """Register or replace the auto-improve interval job for a project.
244
+
245
+ Uses APScheduler IntervalTrigger to fire every ``interval_minutes``.
246
+ On each tick, a single one-shot auto-improve agent session is started
247
+ (unless the project's agent is already running, in which case the tick
248
+ is silently skipped).
249
+ """
250
+ if interval_minutes < 1 or interval_minutes > 1440:
251
+ raise ValueError("interval_minutes must be between 1 and 1440")
252
+
253
+ job_id = self._auto_improve_job_id(project_name)
254
+ trigger = IntervalTrigger(minutes=interval_minutes)
255
+
256
+ self.scheduler.add_job(
257
+ self._handle_auto_improve_tick,
258
+ trigger,
259
+ id=job_id,
260
+ args=[project_name, str(project_dir)],
261
+ replace_existing=True,
262
+ misfire_grace_time=300,
263
+ max_instances=1, # Never overlap ticks
264
+ coalesce=True, # Collapse missed ticks during long runs
265
+ )
266
+
267
+ job = self.scheduler.get_job(job_id)
268
+ next_run = job.next_run_time if job else None
269
+ logger.info(
270
+ f"Registered auto-improve for {project_name}: "
271
+ f"every {interval_minutes} min (next: {next_run})"
272
+ )
273
+
274
+ def remove_auto_improve(self, project_name: str):
275
+ """Remove the auto-improve interval job for a project (no-op if missing)."""
276
+ job_id = self._auto_improve_job_id(project_name)
277
+ try:
278
+ self.scheduler.remove_job(job_id)
279
+ logger.info(f"Removed auto-improve job for {project_name}")
280
+ except Exception:
281
+ logger.debug(f"No auto-improve job to remove for {project_name}")
282
+
283
+ async def _handle_auto_improve_tick(
284
+ self,
285
+ project_name: str,
286
+ project_dir_str: str,
287
+ ):
288
+ """Fire one auto-improve agent session for a project.
289
+
290
+ Silently skips if the agent is already running (manual run, another
291
+ tick still executing, etc.). The scheduler's ``max_instances=1`` and
292
+ ``coalesce=True`` settings make sure ticks never stack up.
293
+ """
294
+ logger.info(f"Auto-improve tick for {project_name}")
295
+ project_dir = Path(project_dir_str)
296
+
297
+ if not project_dir.exists():
298
+ logger.warning(
299
+ f"Auto-improve tick: project dir missing for {project_name}, skipping"
300
+ )
301
+ return
302
+
303
+ try:
304
+ from .process_manager import get_manager
305
+
306
+ root_dir = Path(__file__).parent.parent.parent
307
+ manager = get_manager(project_name, project_dir, root_dir)
308
+
309
+ if manager.status in ("running", "paused", "pausing", "paused_graceful"):
310
+ logger.info(
311
+ f"Auto-improve tick for {project_name}: agent already "
312
+ f"{manager.status}, skipping this tick"
313
+ )
314
+ return
315
+
316
+ # Resolve effective yolo/model from global settings, mirroring the
317
+ # agent router's _get_settings_defaults() pattern so auto-improve
318
+ # respects whatever the user has configured globally.
319
+ yolo_mode, model = self._resolve_agent_defaults()
320
+
321
+ logger.info(
322
+ f"Starting auto-improve agent for {project_name} "
323
+ f"(yolo={yolo_mode}, model={model})"
324
+ )
325
+ success, msg = await manager.start(
326
+ yolo_mode=yolo_mode,
327
+ model=model,
328
+ max_concurrency=1,
329
+ testing_agent_ratio=0,
330
+ playwright_headless=True,
331
+ auto_improve=True,
332
+ )
333
+
334
+ if success:
335
+ logger.info(f"Auto-improve agent started for {project_name}")
336
+ else:
337
+ logger.warning(
338
+ f"Auto-improve agent failed to start for {project_name}: {msg}"
339
+ )
340
+ except Exception as e:
341
+ logger.error(f"Error in auto-improve tick for {project_name}: {e}")
342
+
343
+ @staticmethod
344
+ def _resolve_agent_defaults() -> tuple[bool, str]:
345
+ """Resolve (yolo_mode, model) from global settings.
346
+
347
+ Kept separate from the agent router's helper so the scheduler never
348
+ has to import FastAPI routers. Mirrors the parsing behavior of
349
+ ``server/routers/agent.py::_get_settings_defaults``.
350
+ """
351
+ from registry import DEFAULT_MODEL, get_all_settings
352
+
353
+ settings = get_all_settings()
354
+ yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
355
+ model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
356
+ return yolo_mode, model
357
+
208
358
  async def _handle_scheduled_start(
209
359
  self, project_name: str, schedule_id: int, project_dir_str: str
210
360
  ):
@@ -147,8 +147,9 @@ class SpecChatSession:
147
147
  system_cli = shutil.which("claude")
148
148
 
149
149
  # Build environment overrides for API configuration
150
- from registry import DEFAULT_MODEL, get_effective_sdk_env
150
+ from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
151
151
  sdk_env = get_effective_sdk_env()
152
+ effort = get_effort_setting()
152
153
 
153
154
  # Determine model from SDK env (provider-aware) or fallback to env/default
154
155
  model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -157,6 +158,7 @@ class SpecChatSession:
157
158
  self.client = ClaudeSDKClient(
158
159
  options=ClaudeAgentOptions(
159
160
  model=model,
161
+ effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
160
162
  cli_path=system_cli,
161
163
  # System prompt loaded from CLAUDE.md via setting_sources
162
164
  # Include "user" for global skills and subagents from ~/.claude/