autoforge-ai 0.1.0

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.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. package/ui/package.json +57 -0
@@ -0,0 +1,683 @@
1
+ """
2
+ Agent Scheduler Service
3
+ =======================
4
+
5
+ APScheduler-based service for automated agent scheduling.
6
+ Manages time-based start/stop of agents with crash recovery and manual override tracking.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import sys
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
17
+ from apscheduler.triggers.cron import CronTrigger
18
+
19
+ # Add parent directory for imports
20
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Constants
25
+ MAX_CRASH_RETRIES = 3
26
+ CRASH_BACKOFF_BASE = 10 # seconds
27
+
28
+
29
+ class SchedulerService:
30
+ """
31
+ APScheduler-based service for automated agent scheduling.
32
+
33
+ Creates two jobs per schedule:
34
+ 1. Start job - triggers at start_time on configured days
35
+ 2. Stop job - triggers at start_time + duration on configured days
36
+
37
+ Handles:
38
+ - Manual override tracking (persisted to DB)
39
+ - Crash recovery with exponential backoff
40
+ - Overlapping schedules (latest stop wins)
41
+ - Server restart recovery
42
+ """
43
+
44
+ def __init__(self):
45
+ from datetime import timezone as dt_timezone
46
+
47
+ # CRITICAL: Use UTC timezone since all schedule times are stored in UTC
48
+ self.scheduler = AsyncIOScheduler(timezone=dt_timezone.utc)
49
+ self._started = False
50
+
51
+ async def start(self):
52
+ """Start the scheduler and load all existing schedules."""
53
+ if self._started:
54
+ return
55
+
56
+ self.scheduler.start()
57
+ self._started = True
58
+ logger.info("Scheduler service started")
59
+
60
+ # Check for active schedule windows on startup
61
+ await self._check_missed_windows_on_startup()
62
+
63
+ # Load all schedules from registered projects
64
+ await self._load_all_schedules()
65
+
66
+ async def stop(self):
67
+ """Shutdown the scheduler gracefully."""
68
+ if not self._started:
69
+ return
70
+
71
+ self.scheduler.shutdown(wait=False)
72
+ self._started = False
73
+ logger.info("Scheduler service stopped")
74
+
75
+ async def _load_all_schedules(self):
76
+ """Load schedules for all registered projects."""
77
+ from registry import list_registered_projects
78
+
79
+ try:
80
+ projects = list_registered_projects()
81
+ total_loaded = 0
82
+ for project_name, info in projects.items():
83
+ 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
87
+ if total_loaded > 0:
88
+ logger.info(f"Loaded {total_loaded} schedule(s) across all projects")
89
+ except Exception as e:
90
+ logger.error(f"Error loading schedules: {e}")
91
+
92
+ async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
93
+ """Load schedules for a single project. Returns count of schedules loaded."""
94
+ from api.database import Schedule, create_database
95
+ from autoforge_paths import get_features_db_path
96
+
97
+ db_path = get_features_db_path(project_dir)
98
+ if not db_path.exists():
99
+ return 0
100
+
101
+ try:
102
+ _, SessionLocal = create_database(project_dir)
103
+ db = SessionLocal()
104
+ try:
105
+ schedules = db.query(Schedule).filter(
106
+ Schedule.project_name == project_name,
107
+ Schedule.enabled == True, # noqa: E712
108
+ ).all()
109
+
110
+ for schedule in schedules:
111
+ await self.add_schedule(project_name, schedule, project_dir)
112
+
113
+ if schedules:
114
+ logger.info(f"Loaded {len(schedules)} schedule(s) for project '{project_name}'")
115
+ return len(schedules)
116
+ finally:
117
+ db.close()
118
+ except Exception as e:
119
+ logger.error(f"Error loading schedules for {project_name}: {e}")
120
+ return 0
121
+
122
+ async def add_schedule(self, project_name: str, schedule, project_dir: Path):
123
+ """Create APScheduler jobs for a schedule."""
124
+ try:
125
+ # Convert days bitfield to cron day_of_week string
126
+ days = self._bitfield_to_cron_days(schedule.days_of_week)
127
+
128
+ # Parse start time
129
+ hour, minute = map(int, schedule.start_time.split(":"))
130
+
131
+ # Calculate end time
132
+ start_dt = datetime.strptime(schedule.start_time, "%H:%M")
133
+ end_dt = start_dt + timedelta(minutes=schedule.duration_minutes)
134
+
135
+ # Detect midnight crossing
136
+ crosses_midnight = end_dt.date() != start_dt.date()
137
+
138
+ # Handle midnight wraparound for end time
139
+ end_hour = end_dt.hour
140
+ end_minute = end_dt.minute
141
+
142
+ # Start job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling
143
+ start_job_id = f"schedule_{schedule.id}_start"
144
+ start_trigger = CronTrigger(hour=hour, minute=minute, day_of_week=days, timezone=timezone.utc)
145
+ self.scheduler.add_job(
146
+ self._handle_scheduled_start,
147
+ start_trigger,
148
+ id=start_job_id,
149
+ args=[project_name, schedule.id, str(project_dir)],
150
+ replace_existing=True,
151
+ misfire_grace_time=300, # 5 minutes grace period
152
+ )
153
+
154
+ # Stop job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling
155
+ # If schedule crosses midnight, shift days forward so stop occurs on next day
156
+ stop_job_id = f"schedule_{schedule.id}_stop"
157
+ if crosses_midnight:
158
+ shifted_bitfield = self._shift_days_forward(schedule.days_of_week)
159
+ stop_days = self._bitfield_to_cron_days(shifted_bitfield)
160
+ else:
161
+ stop_days = days
162
+
163
+ stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=stop_days, timezone=timezone.utc)
164
+ self.scheduler.add_job(
165
+ self._handle_scheduled_stop,
166
+ stop_trigger,
167
+ id=stop_job_id,
168
+ args=[project_name, schedule.id, str(project_dir)],
169
+ replace_existing=True,
170
+ misfire_grace_time=300,
171
+ )
172
+
173
+ # Log next run times for monitoring
174
+ start_job = self.scheduler.get_job(start_job_id)
175
+ stop_job = self.scheduler.get_job(stop_job_id)
176
+ logger.info(
177
+ f"Registered schedule {schedule.id} for {project_name}: "
178
+ f"start at {hour:02d}:{minute:02d} UTC (next: {start_job.next_run_time}), "
179
+ f"stop at {end_hour:02d}:{end_minute:02d} UTC (next: {stop_job.next_run_time})"
180
+ )
181
+
182
+ except Exception as e:
183
+ logger.error(f"Error adding schedule {schedule.id}: {e}")
184
+
185
+ def remove_schedule(self, schedule_id: int):
186
+ """Remove APScheduler jobs for a schedule."""
187
+ start_job_id = f"schedule_{schedule_id}_start"
188
+ stop_job_id = f"schedule_{schedule_id}_stop"
189
+
190
+ removed = []
191
+ try:
192
+ self.scheduler.remove_job(start_job_id)
193
+ removed.append("start")
194
+ except Exception:
195
+ pass
196
+
197
+ try:
198
+ self.scheduler.remove_job(stop_job_id)
199
+ removed.append("stop")
200
+ except Exception:
201
+ pass
202
+
203
+ if removed:
204
+ logger.info(f"Removed schedule {schedule_id} jobs: {', '.join(removed)}")
205
+ else:
206
+ logger.warning(f"No jobs found to remove for schedule {schedule_id}")
207
+
208
+ async def _handle_scheduled_start(
209
+ self, project_name: str, schedule_id: int, project_dir_str: str
210
+ ):
211
+ """Handle scheduled agent start."""
212
+ logger.info(f"Scheduled start triggered for {project_name} (schedule {schedule_id})")
213
+ project_dir = Path(project_dir_str)
214
+
215
+ try:
216
+ from api.database import Schedule, ScheduleOverride, create_database
217
+
218
+ _, SessionLocal = create_database(project_dir)
219
+ db = SessionLocal()
220
+
221
+ try:
222
+ schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
223
+ if not schedule or not schedule.enabled:
224
+ return
225
+
226
+ # Check for manual stop override
227
+ now = datetime.now(timezone.utc)
228
+ override = db.query(ScheduleOverride).filter(
229
+ ScheduleOverride.schedule_id == schedule_id,
230
+ ScheduleOverride.override_type == "stop",
231
+ ScheduleOverride.expires_at > now,
232
+ ).first()
233
+
234
+ if override:
235
+ logger.info(
236
+ f"Skipping scheduled start for {project_name}: "
237
+ f"manual stop override active until {override.expires_at}"
238
+ )
239
+ return
240
+
241
+ # Reset crash count at window start
242
+ schedule.crash_count = 0
243
+ db.commit()
244
+
245
+ # Start agent
246
+ await self._start_agent(project_name, project_dir, schedule)
247
+
248
+ finally:
249
+ db.close()
250
+
251
+ except Exception as e:
252
+ logger.error(f"Error in scheduled start for {project_name}: {e}")
253
+
254
+ async def _handle_scheduled_stop(
255
+ self, project_name: str, schedule_id: int, project_dir_str: str
256
+ ):
257
+ """Handle scheduled agent stop."""
258
+ logger.info(f"Scheduled stop triggered for {project_name} (schedule {schedule_id})")
259
+ project_dir = Path(project_dir_str)
260
+
261
+ try:
262
+ from api.database import Schedule, ScheduleOverride, create_database
263
+
264
+ _, SessionLocal = create_database(project_dir)
265
+ db = SessionLocal()
266
+
267
+ try:
268
+ schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
269
+ if not schedule:
270
+ logger.warning(f"Schedule {schedule_id} not found in database")
271
+ return
272
+
273
+ # Check if other schedules are still active (latest stop wins)
274
+ if self._other_schedules_still_active(db, project_name, schedule_id):
275
+ logger.info(
276
+ f"Skipping scheduled stop for {project_name}: "
277
+ f"other schedules still active (latest stop wins)"
278
+ )
279
+ return
280
+
281
+ # Clear expired overrides for this schedule
282
+ now = datetime.now(timezone.utc)
283
+ db.query(ScheduleOverride).filter(
284
+ ScheduleOverride.schedule_id == schedule_id,
285
+ ScheduleOverride.expires_at <= now,
286
+ ).delete()
287
+ db.commit()
288
+
289
+ # Check for active manual-start overrides that prevent auto-stop
290
+ active_start_override = db.query(ScheduleOverride).filter(
291
+ ScheduleOverride.schedule_id == schedule_id,
292
+ ScheduleOverride.override_type == "start",
293
+ ScheduleOverride.expires_at > now,
294
+ ).first()
295
+
296
+ if active_start_override:
297
+ logger.info(
298
+ f"Skipping scheduled stop for {project_name}: "
299
+ f"active manual-start override (expires {active_start_override.expires_at})"
300
+ )
301
+ return
302
+
303
+ # Stop agent
304
+ await self._stop_agent(project_name, project_dir)
305
+
306
+ finally:
307
+ db.close()
308
+
309
+ except Exception as e:
310
+ logger.error(f"Error in scheduled stop for {project_name}: {e}")
311
+
312
+ def _other_schedules_still_active(
313
+ self, db, project_name: str, ending_schedule_id: int
314
+ ) -> bool:
315
+ """Check if any other schedule windows are still active."""
316
+ from api.database import Schedule
317
+
318
+ now = datetime.now(timezone.utc)
319
+ schedules = db.query(Schedule).filter(
320
+ Schedule.project_name == project_name,
321
+ Schedule.enabled == True, # noqa: E712
322
+ Schedule.id != ending_schedule_id,
323
+ ).all()
324
+
325
+ for schedule in schedules:
326
+ if self._is_within_window(schedule, now):
327
+ return True
328
+ return False
329
+
330
+ def _is_within_window(self, schedule, now: datetime) -> bool:
331
+ """Check if current time is within schedule window."""
332
+ # Parse schedule times (keep timezone awareness from now)
333
+ start_hour, start_minute = map(int, schedule.start_time.split(":"))
334
+ start_time = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
335
+
336
+ # Calculate end time
337
+ end_time = start_time + timedelta(minutes=schedule.duration_minutes)
338
+
339
+ # Detect midnight crossing
340
+ crosses_midnight = end_time < start_time or end_time.date() != start_time.date()
341
+
342
+ if crosses_midnight:
343
+ # Check today's window (start_time to midnight) OR yesterday's window (midnight to end_time)
344
+ # Today: if we're after start_time on the current day
345
+ if schedule.is_active_on_day(now.weekday()) and now >= start_time:
346
+ return True
347
+
348
+ # Yesterday: check if we're before end_time and yesterday was active
349
+ yesterday = (now.weekday() - 1) % 7
350
+ if schedule.is_active_on_day(yesterday):
351
+ yesterday_start = start_time - timedelta(days=1)
352
+ yesterday_end = end_time - timedelta(days=1)
353
+ if yesterday_start <= now < yesterday_end:
354
+ return True
355
+
356
+ return False
357
+ else:
358
+ # Normal case: doesn't cross midnight
359
+ return schedule.is_active_on_day(now.weekday()) and start_time <= now < end_time
360
+
361
+ async def _start_agent(self, project_name: str, project_dir: Path, schedule):
362
+ """Start the agent for a project."""
363
+ from .process_manager import get_manager
364
+
365
+ root_dir = Path(__file__).parent.parent.parent
366
+ manager = get_manager(project_name, project_dir, root_dir)
367
+
368
+ if manager.status in ("running", "paused"):
369
+ logger.info(f"Agent already running for {project_name}, skipping scheduled start")
370
+ return
371
+
372
+ # Register crash callback to enable auto-restart during scheduled windows
373
+ async def on_status_change(status: str):
374
+ if status == "crashed":
375
+ logger.info(f"Crash detected for {project_name}, attempting recovery")
376
+ await self.handle_crash_during_window(project_name, project_dir)
377
+
378
+ manager.add_status_callback(on_status_change)
379
+
380
+ logger.info(
381
+ f"Starting agent for {project_name} "
382
+ f"(schedule {schedule.id}, yolo={schedule.yolo_mode}, concurrency={schedule.max_concurrency})"
383
+ )
384
+ success, msg = await manager.start(
385
+ yolo_mode=schedule.yolo_mode,
386
+ model=schedule.model,
387
+ max_concurrency=schedule.max_concurrency,
388
+ )
389
+
390
+ if success:
391
+ logger.info(f"✓ Agent started successfully for {project_name}")
392
+ else:
393
+ logger.error(f"✗ Failed to start agent for {project_name}: {msg}")
394
+ # Remove callback if start failed
395
+ manager.remove_status_callback(on_status_change)
396
+
397
+ async def _stop_agent(self, project_name: str, project_dir: Path):
398
+ """Stop the agent for a project."""
399
+ from .process_manager import get_manager
400
+
401
+ root_dir = Path(__file__).parent.parent.parent
402
+ manager = get_manager(project_name, project_dir, root_dir)
403
+
404
+ if manager.status not in ("running", "paused"):
405
+ logger.info(f"Agent not running for {project_name}, skipping scheduled stop")
406
+ return
407
+
408
+ logger.info(f"Stopping agent for {project_name} (scheduled)")
409
+ success, msg = await manager.stop()
410
+
411
+ if success:
412
+ logger.info(f"✓ Agent stopped successfully for {project_name}")
413
+ else:
414
+ logger.error(f"✗ Failed to stop agent for {project_name}: {msg}")
415
+
416
+ async def handle_crash_during_window(self, project_name: str, project_dir: Path):
417
+ """Called when agent crashes. Attempt restart with backoff."""
418
+ from api.database import Schedule, create_database
419
+
420
+ _, SessionLocal = create_database(project_dir)
421
+ db = SessionLocal()
422
+
423
+ try:
424
+ now = datetime.now(timezone.utc)
425
+ schedules = db.query(Schedule).filter(
426
+ Schedule.project_name == project_name,
427
+ Schedule.enabled == True, # noqa: E712
428
+ ).all()
429
+
430
+ for schedule in schedules:
431
+ if not self._is_within_window(schedule, now):
432
+ continue
433
+
434
+ if schedule.crash_count >= MAX_CRASH_RETRIES:
435
+ logger.warning(
436
+ f"Max crash retries ({MAX_CRASH_RETRIES}) reached for "
437
+ f"schedule {schedule.id} on {project_name}"
438
+ )
439
+ continue
440
+
441
+ schedule.crash_count += 1
442
+ db.commit()
443
+
444
+ # Exponential backoff: 10s, 30s, 90s
445
+ delay = CRASH_BACKOFF_BASE * (3 ** (schedule.crash_count - 1))
446
+ logger.info(
447
+ f"Restarting agent for {project_name} in {delay}s "
448
+ f"(attempt {schedule.crash_count})"
449
+ )
450
+
451
+ await asyncio.sleep(delay)
452
+ await self._start_agent(project_name, project_dir, schedule)
453
+ return # Only restart once
454
+
455
+ finally:
456
+ db.close()
457
+
458
+ def notify_manual_start(self, project_name: str, project_dir: Path):
459
+ """Record manual start to prevent auto-stop."""
460
+ logger.info(f"Manual start detected for {project_name}, creating override to prevent auto-stop")
461
+ self._create_override_for_active_schedules(project_name, project_dir, "start")
462
+
463
+ def notify_manual_stop(self, project_name: str, project_dir: Path):
464
+ """Record manual stop to prevent auto-start."""
465
+ logger.info(f"Manual stop detected for {project_name}, creating override to prevent auto-start")
466
+ self._create_override_for_active_schedules(project_name, project_dir, "stop")
467
+
468
+ def _create_override_for_active_schedules(
469
+ self, project_name: str, project_dir: Path, override_type: str
470
+ ):
471
+ """Create overrides for all active schedule windows.
472
+
473
+ Uses atomic delete-then-create pattern to prevent race conditions.
474
+ """
475
+ from api.database import Schedule, ScheduleOverride, create_database
476
+
477
+ try:
478
+ _, SessionLocal = create_database(project_dir)
479
+ db = SessionLocal()
480
+
481
+ try:
482
+ now = datetime.now(timezone.utc)
483
+ schedules = db.query(Schedule).filter(
484
+ Schedule.project_name == project_name,
485
+ Schedule.enabled == True, # noqa: E712
486
+ ).all()
487
+
488
+ overrides_created = 0
489
+ for schedule in schedules:
490
+ if not self._is_within_window(schedule, now):
491
+ continue
492
+
493
+ # Calculate window end time
494
+ window_end = self._calculate_window_end(schedule, now)
495
+
496
+ # Atomic operation: delete any existing overrides of this type
497
+ # and create a new one in the same transaction
498
+ deleted = db.query(ScheduleOverride).filter(
499
+ ScheduleOverride.schedule_id == schedule.id,
500
+ ScheduleOverride.override_type == override_type,
501
+ ).delete()
502
+
503
+ if deleted:
504
+ logger.debug(
505
+ f"Removed {deleted} existing '{override_type}' override(s) "
506
+ f"for schedule {schedule.id}"
507
+ )
508
+
509
+ # Create new override
510
+ override = ScheduleOverride(
511
+ schedule_id=schedule.id,
512
+ override_type=override_type,
513
+ expires_at=window_end,
514
+ )
515
+ db.add(override)
516
+ overrides_created += 1
517
+ logger.info(
518
+ f"Created '{override_type}' override for schedule {schedule.id} "
519
+ f"(expires at {window_end})"
520
+ )
521
+
522
+ db.commit()
523
+ if overrides_created > 0:
524
+ logger.info(f"Created {overrides_created} override(s) for {project_name}")
525
+
526
+ finally:
527
+ db.close()
528
+
529
+ except Exception as e:
530
+ logger.error(f"Error creating override for {project_name}: {e}")
531
+
532
+ def _calculate_window_end(self, schedule, now: datetime) -> datetime:
533
+ """Calculate when the current window ends."""
534
+ start_hour, start_minute = map(int, schedule.start_time.split(":"))
535
+
536
+ # Create start time for today
537
+ window_start = now.replace(
538
+ hour=start_hour, minute=start_minute, second=0, microsecond=0
539
+ )
540
+
541
+ # If current time is before start time, the window started yesterday
542
+ if now.replace(tzinfo=None) < window_start.replace(tzinfo=None):
543
+ window_start = window_start - timedelta(days=1)
544
+
545
+ window_end = window_start + timedelta(minutes=schedule.duration_minutes)
546
+ return window_end
547
+
548
+ async def _check_missed_windows_on_startup(self):
549
+ """Called on server start. Start agents for any active windows."""
550
+ from registry import list_registered_projects
551
+
552
+ try:
553
+ now = datetime.now(timezone.utc)
554
+ projects = list_registered_projects()
555
+
556
+ for project_name, info in projects.items():
557
+ project_dir = Path(info.get("path", ""))
558
+ if not project_dir.exists():
559
+ continue
560
+
561
+ await self._check_project_on_startup(project_name, project_dir, now)
562
+
563
+ except Exception as e:
564
+ logger.error(f"Error checking missed windows on startup: {e}")
565
+
566
+ async def _check_project_on_startup(
567
+ self, project_name: str, project_dir: Path, now: datetime
568
+ ):
569
+ """Check if a project should be started on server startup."""
570
+ from api.database import Schedule, ScheduleOverride, create_database
571
+ from autoforge_paths import get_features_db_path
572
+
573
+ db_path = get_features_db_path(project_dir)
574
+ if not db_path.exists():
575
+ return
576
+
577
+ try:
578
+ _, SessionLocal = create_database(project_dir)
579
+ db = SessionLocal()
580
+
581
+ try:
582
+ schedules = db.query(Schedule).filter(
583
+ Schedule.project_name == project_name,
584
+ Schedule.enabled == True, # noqa: E712
585
+ ).all()
586
+
587
+ for schedule in schedules:
588
+ if not self._is_within_window(schedule, now):
589
+ continue
590
+
591
+ # Check for manual stop override
592
+ override = db.query(ScheduleOverride).filter(
593
+ ScheduleOverride.schedule_id == schedule.id,
594
+ ScheduleOverride.override_type == "stop",
595
+ ScheduleOverride.expires_at > now,
596
+ ).first()
597
+
598
+ if override:
599
+ logger.info(
600
+ f"Skipping startup start for {project_name}: "
601
+ f"manual stop override active"
602
+ )
603
+ continue
604
+
605
+ # Start the agent
606
+ logger.info(
607
+ f"Starting {project_name} for active schedule {schedule.id} "
608
+ f"(server startup)"
609
+ )
610
+ await self._start_agent(project_name, project_dir, schedule)
611
+ return # Only start once per project
612
+
613
+ finally:
614
+ db.close()
615
+
616
+ except Exception as e:
617
+ logger.error(f"Error checking startup for {project_name}: {e}")
618
+
619
+ @staticmethod
620
+ def _shift_days_forward(bitfield: int) -> int:
621
+ """
622
+ Shift the 7-bit day mask forward by one day for midnight-crossing schedules.
623
+
624
+ Examples:
625
+ Monday (1) -> Tuesday (2)
626
+ Sunday (64) -> Monday (1)
627
+ Mon+Tue (3) -> Tue+Wed (6)
628
+ """
629
+ shifted = 0
630
+ # Shift each day forward, wrapping Sunday to Monday
631
+ if bitfield & 1:
632
+ shifted |= 2 # Mon -> Tue
633
+ if bitfield & 2:
634
+ shifted |= 4 # Tue -> Wed
635
+ if bitfield & 4:
636
+ shifted |= 8 # Wed -> Thu
637
+ if bitfield & 8:
638
+ shifted |= 16 # Thu -> Fri
639
+ if bitfield & 16:
640
+ shifted |= 32 # Fri -> Sat
641
+ if bitfield & 32:
642
+ shifted |= 64 # Sat -> Sun
643
+ if bitfield & 64:
644
+ shifted |= 1 # Sun -> Mon
645
+ return shifted
646
+
647
+ @staticmethod
648
+ def _bitfield_to_cron_days(bitfield: int) -> str:
649
+ """Convert days bitfield to APScheduler cron format."""
650
+ days = []
651
+ day_map = [
652
+ (1, "mon"),
653
+ (2, "tue"),
654
+ (4, "wed"),
655
+ (8, "thu"),
656
+ (16, "fri"),
657
+ (32, "sat"),
658
+ (64, "sun"),
659
+ ]
660
+ for bit, name in day_map:
661
+ if bitfield & bit:
662
+ days.append(name)
663
+ return ",".join(days) if days else "mon-sun"
664
+
665
+
666
+ # Global scheduler instance
667
+ _scheduler: Optional[SchedulerService] = None
668
+
669
+
670
+ def get_scheduler() -> SchedulerService:
671
+ """Get the global scheduler instance."""
672
+ global _scheduler
673
+ if _scheduler is None:
674
+ _scheduler = SchedulerService()
675
+ return _scheduler
676
+
677
+
678
+ async def cleanup_scheduler():
679
+ """Clean up scheduler on shutdown."""
680
+ global _scheduler
681
+ if _scheduler is not None:
682
+ await _scheduler.stop()
683
+ _scheduler = None