tiphys.scheduler.service
Scheduler service for executing background tasks.
Manages the background loop, task execution, and schedule calculation.
1""" 2Scheduler service for executing background tasks. 3 4Manages the background loop, task execution, and schedule calculation. 5""" 6 7import asyncio 8import contextlib 9from datetime import datetime, timedelta 10from typing import TYPE_CHECKING 11from uuid import uuid4 12 13import structlog 14from croniter import croniter # type: ignore[import-untyped] 15 16from tiphys.scheduler.models import ScheduledTask, ScheduleType, TaskRunResult, TaskStatus 17from tiphys.scheduler.store import TaskStore 18 19MAX_STEPS = 10 20 21if TYPE_CHECKING: 22 from tiphys.context import AppContext 23 24logger = structlog.get_logger(__name__) 25 26 27class TaskScheduler: 28 """ 29 Manages background execution of scheduled agent tasks. 30 """ 31 32 def __init__(self, context: "AppContext", store: TaskStore | None = None): 33 """ 34 Initialize the scheduler. 35 36 Args: 37 context: The application context. 38 store: Optional task store override. 39 """ 40 self.context = context 41 self.store = store or TaskStore() 42 self._running = False 43 self._loop_task: asyncio.Task[None] | None = None 44 45 async def start(self) -> None: 46 """Start the background scheduler loop.""" 47 if self._running: 48 return 49 50 self._running = True 51 self._loop_task = asyncio.create_task(self._run_loop()) 52 await logger.ainfo("Task scheduler started") 53 54 async def stop(self) -> None: 55 """Stop the background scheduler loop.""" 56 self._running = False 57 if self._loop_task: 58 self._loop_task.cancel() 59 with contextlib.suppress(asyncio.CancelledError): 60 await self._loop_task 61 self._loop_task = None 62 await logger.ainfo("Task scheduler stopped") 63 64 async def create_task( 65 self, 66 name: str, 67 group: str, 68 prompt: str, 69 schedule_type: ScheduleType, 70 schedule_value: str, 71 agent_id: str = "default", 72 ) -> str: 73 """ 74 Create and schedule a new task. 75 76 Returns: 77 The ID of the created task. 78 """ 79 task_id = str(uuid4()) 80 task = ScheduledTask( 81 id=task_id, 82 name=name, 83 group=group, 84 prompt=prompt, 85 schedule_type=schedule_type, 86 schedule_value=schedule_value, 87 agent_id=agent_id, 88 ) 89 90 # Calculate first run 91 task.next_run = self._calculate_next_run(task) 92 93 self.store.save(task) 94 await logger.ainfo("Task created", task_id=task_id, name=name, next_run=task.next_run) 95 return task_id 96 97 async def pause_task(self, task_id: str) -> bool: 98 """Pause a scheduled task.""" 99 task = self.store.load(task_id) 100 if not task: 101 return False 102 103 task.status = TaskStatus.PAUSED 104 self.store.save(task) 105 await logger.ainfo("Task paused", task_id=task_id) 106 return True 107 108 async def resume_task(self, task_id: str) -> bool: 109 """Resume a paused task.""" 110 task = self.store.load(task_id) 111 if not task: 112 return False 113 114 task.status = TaskStatus.ACTIVE 115 task.next_run = self._calculate_next_run(task) 116 self.store.save(task) 117 await logger.ainfo("Task resumed", task_id=task_id, next_run=task.next_run) 118 return True 119 120 def _calculate_next_run(self, task: ScheduledTask) -> datetime | None: 121 """Calculate the next run time for a task.""" 122 now = datetime.utcnow() 123 124 try: 125 if task.schedule_type == ScheduleType.CRON: 126 iter = croniter(task.schedule_value, now) 127 from typing import cast 128 129 return cast(datetime, iter.get_next(datetime)) 130 131 elif task.schedule_type == ScheduleType.INTERVAL: 132 seconds = int(task.schedule_value) 133 return now + timedelta(seconds=seconds) 134 135 elif task.schedule_type == ScheduleType.ONCE: 136 dt = datetime.fromisoformat(task.schedule_value) 137 return dt if dt > now else None 138 except Exception as e: 139 logger.error("Failed to calculate next run", task_id=task.id, error=str(e)) 140 return None 141 142 return None 143 144 async def _run_loop(self) -> None: 145 """Background loop to check for due tasks.""" 146 while self._running: 147 try: 148 due_tasks = self.store.get_due_tasks() 149 for task in due_tasks: 150 # Run in background to not block the loop 151 asyncio.create_task(self._execute_task(task)) 152 153 # Check every 30 seconds 154 await asyncio.sleep(30) 155 except asyncio.CancelledError: 156 break 157 except Exception as e: 158 logger.error("Error in scheduler loop", error=str(e)) 159 await asyncio.sleep(60) 160 161 async def _execute_task(self, task: ScheduledTask) -> None: 162 """Execute a single due task.""" 163 run_id = str(uuid4()) 164 start_time = datetime.utcnow() 165 166 await logger.ainfo("Executing task", task_id=task.id, name=task.name, run_id=run_id) 167 168 # Mark as running/updating next run so it's not picked up again immediately 169 task.last_run = start_time 170 task.run_count += 1 171 task.next_run = self._calculate_next_run(task) 172 173 # If it was a 'once' task, it might not have a next run 174 if task.schedule_type == ScheduleType.ONCE: 175 task.status = TaskStatus.COMPLETED 176 177 self.store.save(task) 178 179 try: 180 from tiphys.agent.runner import AgentRunner 181 182 # Build a session key for the task if not provided 183 session_key = task.session_key or f"task:{task.id}" 184 185 # Use AppContext to create runner 186 runner = AgentRunner(context=self.context) 187 188 # Execute agent 189 output = await runner.run( 190 session_key=session_key, user_message=task.prompt, max_steps=10 191 ) 192 193 duration = int((datetime.utcnow() - start_time).total_seconds() * 1000) 194 TaskRunResult( 195 task_id=task.id, run_id=run_id, success=True, output=output, duration_ms=duration 196 ) 197 198 await logger.ainfo("Task succeeded", task_id=task.id, duration_ms=duration) 199 # In a real system, we'd log the result to a history store 200 201 except Exception as e: 202 duration = int((datetime.utcnow() - start_time).total_seconds() * 1000) 203 logger.error("Task failed", task_id=task.id, error=str(e)) 204 205 # Optionally mark task as failed if it keeps failing 206 # For now, just keep it active for the next run
MAX_STEPS =
10
logger =
<BoundLoggerLazyProxy(logger=None, wrapper_class=None, processors=None, context_class=None, initial_values={}, logger_factory_args=('tiphys.scheduler.service',))>
class
TaskScheduler:
28class TaskScheduler: 29 """ 30 Manages background execution of scheduled agent tasks. 31 """ 32 33 def __init__(self, context: "AppContext", store: TaskStore | None = None): 34 """ 35 Initialize the scheduler. 36 37 Args: 38 context: The application context. 39 store: Optional task store override. 40 """ 41 self.context = context 42 self.store = store or TaskStore() 43 self._running = False 44 self._loop_task: asyncio.Task[None] | None = None 45 46 async def start(self) -> None: 47 """Start the background scheduler loop.""" 48 if self._running: 49 return 50 51 self._running = True 52 self._loop_task = asyncio.create_task(self._run_loop()) 53 await logger.ainfo("Task scheduler started") 54 55 async def stop(self) -> None: 56 """Stop the background scheduler loop.""" 57 self._running = False 58 if self._loop_task: 59 self._loop_task.cancel() 60 with contextlib.suppress(asyncio.CancelledError): 61 await self._loop_task 62 self._loop_task = None 63 await logger.ainfo("Task scheduler stopped") 64 65 async def create_task( 66 self, 67 name: str, 68 group: str, 69 prompt: str, 70 schedule_type: ScheduleType, 71 schedule_value: str, 72 agent_id: str = "default", 73 ) -> str: 74 """ 75 Create and schedule a new task. 76 77 Returns: 78 The ID of the created task. 79 """ 80 task_id = str(uuid4()) 81 task = ScheduledTask( 82 id=task_id, 83 name=name, 84 group=group, 85 prompt=prompt, 86 schedule_type=schedule_type, 87 schedule_value=schedule_value, 88 agent_id=agent_id, 89 ) 90 91 # Calculate first run 92 task.next_run = self._calculate_next_run(task) 93 94 self.store.save(task) 95 await logger.ainfo("Task created", task_id=task_id, name=name, next_run=task.next_run) 96 return task_id 97 98 async def pause_task(self, task_id: str) -> bool: 99 """Pause a scheduled task.""" 100 task = self.store.load(task_id) 101 if not task: 102 return False 103 104 task.status = TaskStatus.PAUSED 105 self.store.save(task) 106 await logger.ainfo("Task paused", task_id=task_id) 107 return True 108 109 async def resume_task(self, task_id: str) -> bool: 110 """Resume a paused task.""" 111 task = self.store.load(task_id) 112 if not task: 113 return False 114 115 task.status = TaskStatus.ACTIVE 116 task.next_run = self._calculate_next_run(task) 117 self.store.save(task) 118 await logger.ainfo("Task resumed", task_id=task_id, next_run=task.next_run) 119 return True 120 121 def _calculate_next_run(self, task: ScheduledTask) -> datetime | None: 122 """Calculate the next run time for a task.""" 123 now = datetime.utcnow() 124 125 try: 126 if task.schedule_type == ScheduleType.CRON: 127 iter = croniter(task.schedule_value, now) 128 from typing import cast 129 130 return cast(datetime, iter.get_next(datetime)) 131 132 elif task.schedule_type == ScheduleType.INTERVAL: 133 seconds = int(task.schedule_value) 134 return now + timedelta(seconds=seconds) 135 136 elif task.schedule_type == ScheduleType.ONCE: 137 dt = datetime.fromisoformat(task.schedule_value) 138 return dt if dt > now else None 139 except Exception as e: 140 logger.error("Failed to calculate next run", task_id=task.id, error=str(e)) 141 return None 142 143 return None 144 145 async def _run_loop(self) -> None: 146 """Background loop to check for due tasks.""" 147 while self._running: 148 try: 149 due_tasks = self.store.get_due_tasks() 150 for task in due_tasks: 151 # Run in background to not block the loop 152 asyncio.create_task(self._execute_task(task)) 153 154 # Check every 30 seconds 155 await asyncio.sleep(30) 156 except asyncio.CancelledError: 157 break 158 except Exception as e: 159 logger.error("Error in scheduler loop", error=str(e)) 160 await asyncio.sleep(60) 161 162 async def _execute_task(self, task: ScheduledTask) -> None: 163 """Execute a single due task.""" 164 run_id = str(uuid4()) 165 start_time = datetime.utcnow() 166 167 await logger.ainfo("Executing task", task_id=task.id, name=task.name, run_id=run_id) 168 169 # Mark as running/updating next run so it's not picked up again immediately 170 task.last_run = start_time 171 task.run_count += 1 172 task.next_run = self._calculate_next_run(task) 173 174 # If it was a 'once' task, it might not have a next run 175 if task.schedule_type == ScheduleType.ONCE: 176 task.status = TaskStatus.COMPLETED 177 178 self.store.save(task) 179 180 try: 181 from tiphys.agent.runner import AgentRunner 182 183 # Build a session key for the task if not provided 184 session_key = task.session_key or f"task:{task.id}" 185 186 # Use AppContext to create runner 187 runner = AgentRunner(context=self.context) 188 189 # Execute agent 190 output = await runner.run( 191 session_key=session_key, user_message=task.prompt, max_steps=10 192 ) 193 194 duration = int((datetime.utcnow() - start_time).total_seconds() * 1000) 195 TaskRunResult( 196 task_id=task.id, run_id=run_id, success=True, output=output, duration_ms=duration 197 ) 198 199 await logger.ainfo("Task succeeded", task_id=task.id, duration_ms=duration) 200 # In a real system, we'd log the result to a history store 201 202 except Exception as e: 203 duration = int((datetime.utcnow() - start_time).total_seconds() * 1000) 204 logger.error("Task failed", task_id=task.id, error=str(e)) 205 206 # Optionally mark task as failed if it keeps failing 207 # For now, just keep it active for the next run
Manages background execution of scheduled agent tasks.
TaskScheduler( context: tiphys.context.AppContext, store: tiphys.scheduler.store.TaskStore | None = None)
33 def __init__(self, context: "AppContext", store: TaskStore | None = None): 34 """ 35 Initialize the scheduler. 36 37 Args: 38 context: The application context. 39 store: Optional task store override. 40 """ 41 self.context = context 42 self.store = store or TaskStore() 43 self._running = False 44 self._loop_task: asyncio.Task[None] | None = None
Initialize the scheduler.
Args: context: The application context. store: Optional task store override.
async def
start(self) -> None:
46 async def start(self) -> None: 47 """Start the background scheduler loop.""" 48 if self._running: 49 return 50 51 self._running = True 52 self._loop_task = asyncio.create_task(self._run_loop()) 53 await logger.ainfo("Task scheduler started")
Start the background scheduler loop.
async def
stop(self) -> None:
55 async def stop(self) -> None: 56 """Stop the background scheduler loop.""" 57 self._running = False 58 if self._loop_task: 59 self._loop_task.cancel() 60 with contextlib.suppress(asyncio.CancelledError): 61 await self._loop_task 62 self._loop_task = None 63 await logger.ainfo("Task scheduler stopped")
Stop the background scheduler loop.
async def
create_task( self, name: str, group: str, prompt: str, schedule_type: tiphys.scheduler.models.ScheduleType, schedule_value: str, agent_id: str = 'default') -> str:
65 async def create_task( 66 self, 67 name: str, 68 group: str, 69 prompt: str, 70 schedule_type: ScheduleType, 71 schedule_value: str, 72 agent_id: str = "default", 73 ) -> str: 74 """ 75 Create and schedule a new task. 76 77 Returns: 78 The ID of the created task. 79 """ 80 task_id = str(uuid4()) 81 task = ScheduledTask( 82 id=task_id, 83 name=name, 84 group=group, 85 prompt=prompt, 86 schedule_type=schedule_type, 87 schedule_value=schedule_value, 88 agent_id=agent_id, 89 ) 90 91 # Calculate first run 92 task.next_run = self._calculate_next_run(task) 93 94 self.store.save(task) 95 await logger.ainfo("Task created", task_id=task_id, name=name, next_run=task.next_run) 96 return task_id
Create and schedule a new task.
Returns: The ID of the created task.
async def
pause_task(self, task_id: str) -> bool:
98 async def pause_task(self, task_id: str) -> bool: 99 """Pause a scheduled task.""" 100 task = self.store.load(task_id) 101 if not task: 102 return False 103 104 task.status = TaskStatus.PAUSED 105 self.store.save(task) 106 await logger.ainfo("Task paused", task_id=task_id) 107 return True
Pause a scheduled task.
async def
resume_task(self, task_id: str) -> bool:
109 async def resume_task(self, task_id: str) -> bool: 110 """Resume a paused task.""" 111 task = self.store.load(task_id) 112 if not task: 113 return False 114 115 task.status = TaskStatus.ACTIVE 116 task.next_run = self._calculate_next_run(task) 117 self.store.save(task) 118 await logger.ainfo("Task resumed", task_id=task_id, next_run=task.next_run) 119 return True
Resume a paused task.