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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- 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
|