puffinflow 2.dev0__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.
Files changed (55) hide show
  1. puffinflow/__init__.py +132 -0
  2. puffinflow/core/__init__.py +110 -0
  3. puffinflow/core/agent/__init__.py +320 -0
  4. puffinflow/core/agent/base.py +1635 -0
  5. puffinflow/core/agent/checkpoint.py +50 -0
  6. puffinflow/core/agent/context.py +521 -0
  7. puffinflow/core/agent/decorators/__init__.py +90 -0
  8. puffinflow/core/agent/decorators/builder.py +454 -0
  9. puffinflow/core/agent/decorators/flexible.py +714 -0
  10. puffinflow/core/agent/decorators/inspection.py +144 -0
  11. puffinflow/core/agent/dependencies.py +57 -0
  12. puffinflow/core/agent/scheduling/__init__.py +21 -0
  13. puffinflow/core/agent/scheduling/builder.py +160 -0
  14. puffinflow/core/agent/scheduling/exceptions.py +35 -0
  15. puffinflow/core/agent/scheduling/inputs.py +137 -0
  16. puffinflow/core/agent/scheduling/parser.py +209 -0
  17. puffinflow/core/agent/scheduling/scheduler.py +413 -0
  18. puffinflow/core/agent/state.py +141 -0
  19. puffinflow/core/config.py +62 -0
  20. puffinflow/core/coordination/__init__.py +137 -0
  21. puffinflow/core/coordination/agent_group.py +359 -0
  22. puffinflow/core/coordination/agent_pool.py +629 -0
  23. puffinflow/core/coordination/agent_team.py +577 -0
  24. puffinflow/core/coordination/coordinator.py +720 -0
  25. puffinflow/core/coordination/deadlock.py +1759 -0
  26. puffinflow/core/coordination/fluent_api.py +421 -0
  27. puffinflow/core/coordination/primitives.py +478 -0
  28. puffinflow/core/coordination/rate_limiter.py +520 -0
  29. puffinflow/core/observability/__init__.py +47 -0
  30. puffinflow/core/observability/agent.py +139 -0
  31. puffinflow/core/observability/alerting.py +73 -0
  32. puffinflow/core/observability/config.py +127 -0
  33. puffinflow/core/observability/context.py +88 -0
  34. puffinflow/core/observability/core.py +147 -0
  35. puffinflow/core/observability/decorators.py +105 -0
  36. puffinflow/core/observability/events.py +71 -0
  37. puffinflow/core/observability/interfaces.py +196 -0
  38. puffinflow/core/observability/metrics.py +137 -0
  39. puffinflow/core/observability/tracing.py +209 -0
  40. puffinflow/core/reliability/__init__.py +27 -0
  41. puffinflow/core/reliability/bulkhead.py +96 -0
  42. puffinflow/core/reliability/circuit_breaker.py +149 -0
  43. puffinflow/core/reliability/leak_detector.py +122 -0
  44. puffinflow/core/resources/__init__.py +77 -0
  45. puffinflow/core/resources/allocation.py +790 -0
  46. puffinflow/core/resources/pool.py +645 -0
  47. puffinflow/core/resources/quotas.py +567 -0
  48. puffinflow/core/resources/requirements.py +217 -0
  49. puffinflow/version.py +21 -0
  50. puffinflow-2.dev0.dist-info/METADATA +334 -0
  51. puffinflow-2.dev0.dist-info/RECORD +55 -0
  52. puffinflow-2.dev0.dist-info/WHEEL +5 -0
  53. puffinflow-2.dev0.dist-info/entry_points.txt +3 -0
  54. puffinflow-2.dev0.dist-info/licenses/LICENSE +21 -0
  55. puffinflow-2.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """Schedule string parsing for natural language and cron expressions."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from re import Match
6
+ from typing import Callable, Optional
7
+
8
+ from .exceptions import InvalidScheduleError
9
+
10
+
11
+ @dataclass
12
+ class ParsedSchedule:
13
+ """Result of parsing a schedule string."""
14
+
15
+ schedule_type: str # "cron", "interval", or "natural"
16
+ cron_expression: Optional[str] = None
17
+ interval_seconds: Optional[int] = None
18
+ description: str = ""
19
+
20
+
21
+ class ScheduleParser:
22
+ """Parser for schedule strings supporting natural language and cron."""
23
+
24
+ # Natural language patterns
25
+ NATURAL_PATTERNS: dict[str, Callable[[Match[str]], ParsedSchedule]] = {
26
+ # Basic intervals
27
+ r"^hourly$": lambda m: ParsedSchedule(
28
+ "cron", "0 * * * *", description="Every hour"
29
+ ),
30
+ r"^daily$": lambda m: ParsedSchedule(
31
+ "cron", "0 0 * * *", description="Every day at midnight"
32
+ ),
33
+ r"^weekly$": lambda m: ParsedSchedule(
34
+ "cron", "0 0 * * 0", description="Every Sunday at midnight"
35
+ ),
36
+ r"^monthly$": lambda m: ParsedSchedule(
37
+ "cron", "0 0 1 * *", description="First day of every month"
38
+ ),
39
+ # Daily with time
40
+ r"^daily\s+at\s+(\d{1,2}):(\d{2})$": lambda m: ParsedSchedule(
41
+ "cron",
42
+ f"{int(m.group(2))} {int(m.group(1))} * * *",
43
+ description=f"Every day at {m.group(1)}:{m.group(2)}",
44
+ ),
45
+ r"^daily\s+at\s+(\d{1,2})(?::00)?(?:\s*am)?$": lambda m: ParsedSchedule(
46
+ "cron",
47
+ f"0 {int(m.group(1))} * * *",
48
+ description=f"Every day at {m.group(1)}:00",
49
+ ),
50
+ r"^daily\s+at\s+(\d{1,2})(?::00)?\s*pm$": lambda m: ParsedSchedule(
51
+ "cron",
52
+ f"0 {int(m.group(1)) + 12} * * *",
53
+ description=f"Every day at {int(m.group(1)) + 12}:00",
54
+ ),
55
+ # Every X minutes/hours
56
+ r"^every\s+(\d+)\s+minutes?$": lambda m: ParsedSchedule(
57
+ "interval",
58
+ interval_seconds=int(m.group(1)) * 60,
59
+ description=f"Every {m.group(1)} {'minute' if int(m.group(1)) == 1 else 'minutes'}",
60
+ ),
61
+ r"^every\s+(\d+)\s+hours?$": lambda m: ParsedSchedule(
62
+ "interval",
63
+ interval_seconds=int(m.group(1)) * 3600,
64
+ description=f"Every {m.group(1)} {'hour' if int(m.group(1)) == 1 else 'hours'}",
65
+ ),
66
+ r"^every\s+(\d+)\s+seconds?$": lambda m: ParsedSchedule(
67
+ "interval",
68
+ interval_seconds=int(m.group(1)),
69
+ description=f"Every {m.group(1)} {'second' if int(m.group(1)) == 1 else 'seconds'}",
70
+ ),
71
+ # Every hour at minute
72
+ r"^every\s+hour\s+at\s+(\d{1,2})$": lambda m: ParsedSchedule(
73
+ "cron",
74
+ f"{int(m.group(1))} * * * *",
75
+ description=f"Every hour at minute {m.group(1)}",
76
+ ),
77
+ # Weekdays
78
+ r"^weekdays$": lambda m: ParsedSchedule(
79
+ "cron", "0 9 * * 1-5", description="Weekdays at 9 AM"
80
+ ),
81
+ r"^weekdays\s+at\s+(\d{1,2}):(\d{2})$": lambda m: ParsedSchedule(
82
+ "cron",
83
+ f"{int(m.group(2))} {int(m.group(1))} * * 1-5",
84
+ description=f"Weekdays at {m.group(1)}:{m.group(2)}",
85
+ ),
86
+ # Weekends
87
+ r"^weekends$": lambda m: ParsedSchedule(
88
+ "cron", "0 10 * * 0,6", description="Weekends at 10 AM"
89
+ ),
90
+ r"^weekends\s+at\s+(\d{1,2}):(\d{2})$": lambda m: ParsedSchedule(
91
+ "cron",
92
+ f"{int(m.group(2))} {int(m.group(1))} * * 0,6",
93
+ description=f"Weekends at {m.group(1)}:{m.group(2)}",
94
+ ),
95
+ }
96
+
97
+ @classmethod
98
+ def parse(cls, schedule_string: str) -> ParsedSchedule:
99
+ """Parse a schedule string.
100
+
101
+ Args:
102
+ schedule_string: Natural language or cron expression
103
+
104
+ Returns:
105
+ ParsedSchedule object
106
+
107
+ Raises:
108
+ InvalidScheduleError: If schedule string is invalid
109
+ """
110
+ if not schedule_string or not schedule_string.strip():
111
+ raise InvalidScheduleError(
112
+ schedule_string, "Schedule string cannot be empty"
113
+ )
114
+
115
+ schedule_string = schedule_string.strip().lower()
116
+
117
+ # Try natural language patterns first
118
+ for pattern, handler in cls.NATURAL_PATTERNS.items():
119
+ match = re.match(pattern, schedule_string, re.IGNORECASE)
120
+ if match:
121
+ return handler(match)
122
+
123
+ # Try as cron expression
124
+ if cls._is_valid_cron(schedule_string):
125
+ return ParsedSchedule(
126
+ "cron", schedule_string, description=f"Cron: {schedule_string}"
127
+ )
128
+
129
+ # If nothing matches, raise error with suggestions
130
+ raise InvalidScheduleError(schedule_string)
131
+
132
+ @staticmethod
133
+ def _is_valid_cron(expression: str) -> bool:
134
+ """Validate cron expression format.
135
+
136
+ Args:
137
+ expression: Cron expression to validate
138
+
139
+ Returns:
140
+ True if valid cron format
141
+ """
142
+ if not expression:
143
+ return False
144
+
145
+ parts = expression.split()
146
+ if len(parts) != 5:
147
+ return False
148
+
149
+ # Basic validation patterns for each field
150
+ patterns = [
151
+ r"^(\*|[0-5]?\d(-[0-5]?\d)?(/\d+)?|\*/\d+)$", # minute (0-59)
152
+ r"^(\*|\d{1,2}(-\d{1,2})?(/\d+)?|\*/\d+)$", # hour (0-23)
153
+ r"^(\*|[1-3]?\d(-[1-3]?\d)?(/\d+)?|\*/\d+)$", # day (1-31)
154
+ r"^(\*|1?\d(-1?\d)?(/\d+)?|\*/\d+)$", # month (1-12)
155
+ r"^(\*|[0-6](-[0-6])?(/\d+)?|\*/\d+)$", # day of week (0-6)
156
+ ]
157
+
158
+ for i, part in enumerate(parts):
159
+ if not re.match(patterns[i], part):
160
+ return False
161
+
162
+ # Additional range validation
163
+ try:
164
+ # Check minute (0-59)
165
+ if parts[0] != "*" and not parts[0].startswith("*/"):
166
+ minute_val = int(parts[0].split("-")[0].split("/")[0])
167
+ if minute_val > 59:
168
+ return False
169
+
170
+ # Check hour (0-23)
171
+ if parts[1] != "*" and not parts[1].startswith("*/"):
172
+ hour_val = int(parts[1].split("-")[0].split("/")[0])
173
+ if hour_val > 23:
174
+ return False
175
+
176
+ # Check day (1-31)
177
+ if parts[2] != "*" and not parts[2].startswith("*/"):
178
+ day_val = int(parts[2].split("-")[0].split("/")[0])
179
+ if day_val < 1 or day_val > 31:
180
+ return False
181
+
182
+ # Check month (1-12)
183
+ if parts[3] != "*" and not parts[3].startswith("*/"):
184
+ month_val = int(parts[3].split("-")[0].split("/")[0])
185
+ if month_val < 1 or month_val > 12:
186
+ return False
187
+
188
+ # Check day of week (0-6)
189
+ if parts[4] != "*" and not parts[4].startswith("*/"):
190
+ dow_val = int(parts[4].split("-")[0].split("/")[0])
191
+ if dow_val > 6:
192
+ return False
193
+
194
+ except (ValueError, IndexError):
195
+ return False
196
+
197
+ return True
198
+
199
+
200
+ def parse_schedule_string(schedule: str) -> ParsedSchedule:
201
+ """Parse a schedule string (convenience function).
202
+
203
+ Args:
204
+ schedule: Schedule string to parse
205
+
206
+ Returns:
207
+ ParsedSchedule object
208
+ """
209
+ return ScheduleParser.parse(schedule)
@@ -0,0 +1,413 @@
1
+ """Global scheduler and scheduled agent implementation."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import time
7
+ import weakref
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timedelta
10
+ from typing import TYPE_CHECKING, Any, Optional
11
+
12
+ from .exceptions import SchedulingError
13
+ from .inputs import ScheduledInput, parse_inputs
14
+ from .parser import ParsedSchedule, parse_schedule_string
15
+
16
+ if TYPE_CHECKING:
17
+ from ..base import Agent, AgentResult
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class ScheduledJob:
24
+ """Represents a scheduled job."""
25
+
26
+ job_id: str
27
+ agent_ref: weakref.ReferenceType # Weak reference to agent
28
+ schedule: ParsedSchedule
29
+ inputs: dict[str, ScheduledInput]
30
+ next_run: float
31
+ last_run: Optional[float] = None
32
+ run_count: int = 0
33
+ is_running: bool = False
34
+ created_at: float = field(default_factory=time.time)
35
+
36
+ @property
37
+ def agent(self) -> Optional["Agent"]:
38
+ """Get the agent if it still exists."""
39
+ return self.agent_ref() if self.agent_ref else None
40
+
41
+ def calculate_next_run(self) -> float:
42
+ """Calculate the next run time based on schedule."""
43
+ now = time.time()
44
+
45
+ if self.schedule.schedule_type == "interval":
46
+ if self.last_run is None:
47
+ return now # Run immediately for first time
48
+ interval = self.schedule.interval_seconds or 60 # Default to 60 seconds
49
+ return self.last_run + interval
50
+
51
+ elif self.schedule.schedule_type == "cron":
52
+ # For cron expressions, we'll use a simple approximation
53
+ # In a production system, you'd use a proper cron library
54
+ return self._calculate_next_cron_run(now)
55
+
56
+ return now + 3600 # Default to 1 hour if unknown type
57
+
58
+ def _calculate_next_cron_run(self, from_time: float) -> float:
59
+ """Calculate next cron run time (simplified implementation)."""
60
+ # This is a simplified cron calculator
61
+ # For production, use a library like croniter
62
+
63
+ cron = self.schedule.cron_expression
64
+ if not cron:
65
+ return from_time + 3600
66
+
67
+ # Handle some common patterns
68
+ if cron == "0 * * * *": # hourly
69
+ dt = datetime.fromtimestamp(from_time)
70
+ next_dt = dt.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
71
+ return next_dt.timestamp()
72
+
73
+ elif cron == "0 0 * * *": # daily at midnight
74
+ dt = datetime.fromtimestamp(from_time)
75
+ next_dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(
76
+ days=1
77
+ )
78
+ return next_dt.timestamp()
79
+
80
+ elif cron.startswith("0 ") and " * * *" in cron: # daily at specific hour
81
+ parts = cron.split()
82
+ if len(parts) >= 2:
83
+ try:
84
+ hour = int(parts[1])
85
+ dt = datetime.fromtimestamp(from_time)
86
+ next_dt = dt.replace(hour=hour, minute=0, second=0, microsecond=0)
87
+ if next_dt <= dt:
88
+ next_dt += timedelta(days=1)
89
+ return next_dt.timestamp()
90
+ except ValueError:
91
+ pass
92
+
93
+ # Default fallback
94
+ return from_time + 3600
95
+
96
+
97
+ class ScheduledAgent:
98
+ """Represents a scheduled agent execution."""
99
+
100
+ def __init__(
101
+ self,
102
+ job_id: str,
103
+ agent: "Agent",
104
+ schedule: ParsedSchedule,
105
+ inputs: dict[str, ScheduledInput],
106
+ ):
107
+ self.job_id = job_id
108
+ self.agent = agent
109
+ self.schedule = schedule
110
+ self.inputs = inputs
111
+ self.created_at = time.time()
112
+
113
+ def cancel(self) -> bool:
114
+ """Cancel this scheduled execution.
115
+
116
+ Returns:
117
+ True if successfully cancelled
118
+ """
119
+ return GlobalScheduler.get_instance_sync().cancel_job(self.job_id)
120
+
121
+ def get_next_run_time(self) -> Optional[datetime]:
122
+ """Get the next scheduled run time.
123
+
124
+ Returns:
125
+ Next run time as datetime, or None if not found
126
+ """
127
+ scheduler = GlobalScheduler.get_instance_sync()
128
+ job = scheduler._jobs.get(self.job_id)
129
+ if job:
130
+ return datetime.fromtimestamp(job.next_run)
131
+ return None
132
+
133
+ def get_run_count(self) -> int:
134
+ """Get the number of times this job has run.
135
+
136
+ Returns:
137
+ Run count
138
+ """
139
+ scheduler = GlobalScheduler.get_instance_sync()
140
+ job = scheduler._jobs.get(self.job_id)
141
+ return job.run_count if job else 0
142
+
143
+
144
+ class GlobalScheduler:
145
+ """Global scheduler for managing scheduled agent executions."""
146
+
147
+ _instance: Optional["GlobalScheduler"] = None
148
+ _lock = asyncio.Lock()
149
+
150
+ def __init__(self) -> None:
151
+ self._jobs: dict[str, ScheduledJob] = {}
152
+ self._running = False
153
+ self._scheduler_task: Optional[asyncio.Task] = None
154
+ self._check_interval = 10.0 # Check every 10 seconds
155
+ self._job_counter = 0
156
+
157
+ @classmethod
158
+ async def get_instance(cls) -> "GlobalScheduler":
159
+ """Get or create the global scheduler instance."""
160
+ if cls._instance is None:
161
+ async with cls._lock:
162
+ if cls._instance is None:
163
+ cls._instance = cls()
164
+ await cls._instance.start()
165
+ return cls._instance
166
+
167
+ @classmethod
168
+ def get_instance_sync(cls) -> "GlobalScheduler":
169
+ """Get the global scheduler instance synchronously (for non-async contexts)."""
170
+ if cls._instance is None:
171
+ cls._instance = cls()
172
+ # Try to start scheduler if event loop is available
173
+ try:
174
+ loop = asyncio.get_event_loop()
175
+ if loop.is_running():
176
+ # Create task but don't need to store reference as it's just startup
177
+ task = asyncio.create_task(cls._instance.start())
178
+ task.add_done_callback(lambda t: None) # Prevent warnings
179
+ except RuntimeError:
180
+ # No event loop running, scheduler will start when needed
181
+ pass
182
+ return cls._instance
183
+
184
+ async def start(self) -> None:
185
+ """Start the scheduler background task."""
186
+ if not self._running:
187
+ self._running = True
188
+ self._scheduler_task = asyncio.create_task(self._scheduler_loop())
189
+ logger.info("Global scheduler started")
190
+
191
+ async def stop(self) -> None:
192
+ """Stop the scheduler background task."""
193
+ self._running = False
194
+ if self._scheduler_task:
195
+ self._scheduler_task.cancel()
196
+ with contextlib.suppress(asyncio.CancelledError):
197
+ await self._scheduler_task
198
+ self._scheduler_task = None
199
+ logger.info("Global scheduler stopped")
200
+
201
+ def schedule_agent(
202
+ self, agent: "Agent", schedule_string: str, **inputs: Any
203
+ ) -> ScheduledAgent:
204
+ """Schedule an agent for execution.
205
+
206
+ Args:
207
+ agent: Agent to schedule
208
+ schedule_string: Schedule string (natural language or cron)
209
+ **inputs: Input parameters with magic prefixes
210
+
211
+ Returns:
212
+ ScheduledAgent instance
213
+
214
+ Raises:
215
+ SchedulingError: If scheduling fails
216
+ """
217
+ try:
218
+ # Parse schedule
219
+ parsed_schedule = parse_schedule_string(schedule_string)
220
+
221
+ # Parse inputs
222
+ parsed_inputs = parse_inputs(**inputs)
223
+
224
+ # Generate job ID
225
+ self._job_counter += 1
226
+ job_id = f"{agent.name}_{self._job_counter}_{int(time.time())}"
227
+
228
+ # Create job
229
+ job = ScheduledJob(
230
+ job_id=job_id,
231
+ agent_ref=weakref.ref(agent),
232
+ schedule=parsed_schedule,
233
+ inputs=parsed_inputs,
234
+ next_run=time.time(), # Will be recalculated
235
+ )
236
+ job.next_run = job.calculate_next_run()
237
+
238
+ # Store job
239
+ self._jobs[job_id] = job
240
+
241
+ # Start scheduler if not running
242
+ if not self._running:
243
+ try:
244
+ loop = asyncio.get_event_loop()
245
+ if loop.is_running():
246
+ # Create task but don't need to store reference as it's just startup
247
+ task = asyncio.create_task(self.start())
248
+ task.add_done_callback(lambda t: None) # Prevent warnings
249
+ except RuntimeError:
250
+ # No event loop, will start when one is available
251
+ pass
252
+
253
+ logger.info(
254
+ f"Scheduled agent {agent.name} with schedule '{schedule_string}' (job_id: {job_id})"
255
+ )
256
+
257
+ return ScheduledAgent(job_id, agent, parsed_schedule, parsed_inputs)
258
+
259
+ except Exception as e:
260
+ raise SchedulingError(f"Failed to schedule agent {agent.name}: {e}") from e
261
+
262
+ def cancel_job(self, job_id: str) -> bool:
263
+ """Cancel a scheduled job.
264
+
265
+ Args:
266
+ job_id: Job ID to cancel
267
+
268
+ Returns:
269
+ True if job was cancelled
270
+ """
271
+ if job_id in self._jobs:
272
+ del self._jobs[job_id]
273
+ logger.info(f"Cancelled scheduled job {job_id}")
274
+ return True
275
+ return False
276
+
277
+ def get_scheduled_jobs(
278
+ self, agent_name: Optional[str] = None
279
+ ) -> list[dict[str, Any]]:
280
+ """Get information about scheduled jobs.
281
+
282
+ Args:
283
+ agent_name: Filter by agent name (optional)
284
+
285
+ Returns:
286
+ List of job information dictionaries
287
+ """
288
+ jobs = []
289
+ for job_id, job in self._jobs.items():
290
+ agent = job.agent
291
+ if agent is None:
292
+ continue # Agent was garbage collected
293
+
294
+ if agent_name and agent.name != agent_name:
295
+ continue
296
+
297
+ jobs.append(
298
+ {
299
+ "job_id": job_id,
300
+ "agent_name": agent.name,
301
+ "schedule_description": job.schedule.description,
302
+ "next_run": datetime.fromtimestamp(job.next_run),
303
+ "last_run": (
304
+ datetime.fromtimestamp(job.last_run) if job.last_run else None
305
+ ),
306
+ "run_count": job.run_count,
307
+ "is_running": job.is_running,
308
+ "created_at": datetime.fromtimestamp(job.created_at),
309
+ }
310
+ )
311
+
312
+ return jobs
313
+
314
+ async def _scheduler_loop(self) -> None:
315
+ """Main scheduler loop."""
316
+ logger.info("Scheduler loop started")
317
+
318
+ while self._running:
319
+ try:
320
+ await self._check_and_run_jobs()
321
+ await asyncio.sleep(self._check_interval)
322
+ except asyncio.CancelledError:
323
+ break
324
+ except Exception as e:
325
+ logger.error(f"Error in scheduler loop: {e}")
326
+ await asyncio.sleep(self._check_interval)
327
+
328
+ logger.info("Scheduler loop stopped")
329
+
330
+ async def _check_and_run_jobs(self) -> None:
331
+ """Check for jobs that need to run and execute them."""
332
+ now = time.time()
333
+ jobs_to_run = []
334
+
335
+ # Find jobs that need to run
336
+ for job_id, job in list(self._jobs.items()):
337
+ agent = job.agent
338
+ if agent is None:
339
+ # Agent was garbage collected, remove job
340
+ del self._jobs[job_id]
341
+ continue
342
+
343
+ if not job.is_running and job.next_run <= now:
344
+ jobs_to_run.append(job)
345
+
346
+ # Run jobs
347
+ for job in jobs_to_run:
348
+ # Create job execution task and store reference to prevent garbage collection
349
+ task = asyncio.create_task(self._run_job(job))
350
+ if not hasattr(self, "_background_tasks"):
351
+ self._background_tasks = set()
352
+ self._background_tasks.add(task)
353
+ task.add_done_callback(lambda t: self._background_tasks.discard(t))
354
+
355
+ async def _run_job(self, job: ScheduledJob) -> None:
356
+ """Run a scheduled job.
357
+
358
+ Args:
359
+ job: Job to run
360
+ """
361
+ agent = job.agent
362
+ if agent is None:
363
+ return
364
+
365
+ job.is_running = True
366
+ job.last_run = time.time()
367
+ job.run_count += 1
368
+
369
+ try:
370
+ logger.info(f"Running scheduled job {job.job_id} for agent {agent.name}")
371
+
372
+ # Create context with scheduled inputs
373
+ context = agent._create_context(agent.shared_state)
374
+
375
+ # Apply scheduled inputs to context
376
+ for scheduled_input in job.inputs.values():
377
+ scheduled_input.apply_to_context(context)
378
+
379
+ # Run the agent
380
+ result: AgentResult = await agent.run()
381
+
382
+ logger.info(
383
+ f"Completed scheduled job {job.job_id} for agent {agent.name} (status: {result.status})"
384
+ )
385
+
386
+ except Exception as e:
387
+ logger.error(
388
+ f"Error running scheduled job {job.job_id} for agent {agent.name}: {e}"
389
+ )
390
+
391
+ finally:
392
+ job.is_running = False
393
+ # Calculate next run time
394
+ job.next_run = job.calculate_next_run()
395
+
396
+ def cleanup_dead_jobs(self) -> int:
397
+ """Remove jobs for agents that have been garbage collected.
398
+
399
+ Returns:
400
+ Number of jobs cleaned up
401
+ """
402
+ dead_jobs = []
403
+ for job_id, job in self._jobs.items():
404
+ if job.agent is None:
405
+ dead_jobs.append(job_id)
406
+
407
+ for job_id in dead_jobs:
408
+ del self._jobs[job_id]
409
+
410
+ if dead_jobs:
411
+ logger.info(f"Cleaned up {len(dead_jobs)} dead scheduled jobs")
412
+
413
+ return len(dead_jobs)