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.
- puffinflow/__init__.py +132 -0
- puffinflow/core/__init__.py +110 -0
- puffinflow/core/agent/__init__.py +320 -0
- puffinflow/core/agent/base.py +1635 -0
- puffinflow/core/agent/checkpoint.py +50 -0
- puffinflow/core/agent/context.py +521 -0
- puffinflow/core/agent/decorators/__init__.py +90 -0
- puffinflow/core/agent/decorators/builder.py +454 -0
- puffinflow/core/agent/decorators/flexible.py +714 -0
- puffinflow/core/agent/decorators/inspection.py +144 -0
- puffinflow/core/agent/dependencies.py +57 -0
- puffinflow/core/agent/scheduling/__init__.py +21 -0
- puffinflow/core/agent/scheduling/builder.py +160 -0
- puffinflow/core/agent/scheduling/exceptions.py +35 -0
- puffinflow/core/agent/scheduling/inputs.py +137 -0
- puffinflow/core/agent/scheduling/parser.py +209 -0
- puffinflow/core/agent/scheduling/scheduler.py +413 -0
- puffinflow/core/agent/state.py +141 -0
- puffinflow/core/config.py +62 -0
- puffinflow/core/coordination/__init__.py +137 -0
- puffinflow/core/coordination/agent_group.py +359 -0
- puffinflow/core/coordination/agent_pool.py +629 -0
- puffinflow/core/coordination/agent_team.py +577 -0
- puffinflow/core/coordination/coordinator.py +720 -0
- puffinflow/core/coordination/deadlock.py +1759 -0
- puffinflow/core/coordination/fluent_api.py +421 -0
- puffinflow/core/coordination/primitives.py +478 -0
- puffinflow/core/coordination/rate_limiter.py +520 -0
- puffinflow/core/observability/__init__.py +47 -0
- puffinflow/core/observability/agent.py +139 -0
- puffinflow/core/observability/alerting.py +73 -0
- puffinflow/core/observability/config.py +127 -0
- puffinflow/core/observability/context.py +88 -0
- puffinflow/core/observability/core.py +147 -0
- puffinflow/core/observability/decorators.py +105 -0
- puffinflow/core/observability/events.py +71 -0
- puffinflow/core/observability/interfaces.py +196 -0
- puffinflow/core/observability/metrics.py +137 -0
- puffinflow/core/observability/tracing.py +209 -0
- puffinflow/core/reliability/__init__.py +27 -0
- puffinflow/core/reliability/bulkhead.py +96 -0
- puffinflow/core/reliability/circuit_breaker.py +149 -0
- puffinflow/core/reliability/leak_detector.py +122 -0
- puffinflow/core/resources/__init__.py +77 -0
- puffinflow/core/resources/allocation.py +790 -0
- puffinflow/core/resources/pool.py +645 -0
- puffinflow/core/resources/quotas.py +567 -0
- puffinflow/core/resources/requirements.py +217 -0
- puffinflow/version.py +21 -0
- puffinflow-2.dev0.dist-info/METADATA +334 -0
- puffinflow-2.dev0.dist-info/RECORD +55 -0
- puffinflow-2.dev0.dist-info/WHEEL +5 -0
- puffinflow-2.dev0.dist-info/entry_points.txt +3 -0
- puffinflow-2.dev0.dist-info/licenses/LICENSE +21 -0
- 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)
|