Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Changelog
==================

- Improves os process control, fix graceful exit
- CRON tasks now has OR logic for calendar and week days
- Periodic/cron graceful cancellation


1.8.0 (2026-02-26)
Expand Down
4 changes: 2 additions & 2 deletions microagent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,11 @@ def cron(spec: str, timeout: int | float = 1) -> abc.Callable[[PeriodicFunc], Pe

.. code-block:: python

@periodic('0 */4 * * *')
@cron('0 */4 * * *')
async def handler_1(self):
log.info('Called handler 1')

@periodic('*/15 * * * *', timeout=10)
@cron('*/15 * * * *', timeout=10)
async def handler_2(self):
log.info('Called handler 2')
'''
Expand Down
6 changes: 5 additions & 1 deletion microagent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ async def start(self,
await self.run_servers(self.hook.servers)

async def stop(self) -> None:
for periodic_task in self.periodic_tasks:
periodic_task.cancel()
for cron_task in self.cron_tasks:
cron_task.cancel()
await self.hook.pre_stop()

@staticmethod
Expand All @@ -220,7 +224,7 @@ def run_periodic_tasks(self, periodic_tasks: Iterable[PeriodicTask],
start_at = datetime.now(tz=timezone.utc) + timedelta(seconds=start_after)
self.log.debug('Set %s at %s', task, f'{start_at:%H:%M:%S}')
else:
self.log.debug('Set %s after %d sec', task, start_after)
self.log.debug('Set %s after %s sec', task, start_after)

task.start(start_after)

Expand Down
41 changes: 31 additions & 10 deletions microagent/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def cron_handler(self):

RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
MAX_DIFF = 2 * 356 * 24 * 60 * 60
MAX_DIFF = 2 * 365 * 24 * 60 * 60


class CRON(NamedTuple):
Expand All @@ -58,6 +58,8 @@ class PeriodicMixin:
agent: 'MicroAgent'
handler: Callable
timeout: float
_timer_handle: asyncio.TimerHandle | None = None
_running_task: asyncio.Task | None = None

async def call(self) -> None:
try:
Expand All @@ -73,7 +75,13 @@ async def call(self) -> None:
self.agent.log.exception(f'Periodic Exception: {exc}')

def start(self, start_after: float) -> None:
asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type]
self._timer_handle = asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type]

def cancel(self) -> None:
if self._timer_handle is not None:
self._timer_handle.cancel()
if self._running_task is not None and not self._running_task.done():
self._running_task.cancel()


class PeriodicArgs(TypedDict):
Expand Down Expand Up @@ -123,17 +131,14 @@ def start_after(self) -> float:

@property
def period(self) -> float:
'''
*period* property of **CRONTask** object is a next value of
generator behind facade. Be carefully with manual manipulation with it.
'''
self.agent.log.debug('Run %r', self)
return self.cron.next().timestamp() - time.time() # next step delay


def _periodic(task: PeriodicTask | CRONTask) -> asyncio.Task:
asyncio.get_running_loop().call_later(task.period, _periodic, task)
return asyncio.create_task(task.call())
task._timer_handle = asyncio.get_running_loop().call_later(task.period, _periodic, task)
running = asyncio.create_task(task.call())
task._running_task = running
return running


def cron_parser(spec: str) -> CRON:
Expand Down Expand Up @@ -201,7 +206,23 @@ def next_moment(cron: CRON, now: datetime) -> datetime:

return next_moment(cron, now)

if now.day not in cron.days or now.weekday() not in cron.weekdays:
# calculating whether a task needs to be started on that day
days_all = set(cron.days) == set(range(1, 32)) # run every calendar day
wdays_all = set(cron.weekdays) >= set(range(7)) # run every week day

day_ok = now.day in cron.days # run today
wday_ok = now.weekday() in cron.weekdays # run today

if not days_all and not wdays_all:
ok = day_ok or wday_ok # POSIX: both restricted → OR
elif not days_all:
ok = day_ok
elif not wdays_all:
ok = wday_ok
else: # if run every day
ok = True

if not ok:
now += timedelta(days=1)
now = datetime(year=now.year, month=now.month, day=now.day, tzinfo=timezone.utc)
return next_moment(cron, now)
Expand Down
Loading