from __future__ import annotations import asyncio import typing import datetime import time from . import app from . import entities as core_entities from .errors import TaskCapacityError from .task_boundary import create_detached_task class TaskContext: """Task tracking context""" current_action: str """Current action being executed""" log: str """Log""" metadata: dict """Structured metadata for progress reporting""" def __init__(self, max_log_chars: int = 200000): self.current_action = 'default' self.log = '' self.metadata = {} self.max_log_chars = max(int(max_log_chars), 1) def _log(self, msg: str): self.log += msg + '\n' if len(self.log) > self.max_log_chars: marker = '[older task output truncated]\n' keep = max(self.max_log_chars - len(marker), 0) self.log = marker + (self.log[-keep:] if keep else '') def set_current_action(self, action: str): self.current_action = action def trace( self, msg: str, action: str = None, ): if action is not None: self.set_current_action(action) self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}') def to_dict(self) -> dict: return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata} @staticmethod def new() -> TaskContext: return TaskContext() @staticmethod def placeholder() -> TaskContext: global placeholder_context if placeholder_context is None: placeholder_context = TaskContext() return placeholder_context placeholder_context: TaskContext | None = None class TaskWrapper: """Task wrapper""" _id_index: int = 0 """Task ID index""" id: int """Task ID""" task_type: str = 'system' # Task type: system or user """Task type""" kind: str = 'system_task' # Task type determined by the initiator, usually the same task type """Task type""" name: str = '' """Task unique name""" label: str = '' """Task display name""" task_context: TaskContext """Task context""" task: asyncio.Task """Task""" task_stack: list = None """Task stack""" ap: app.Application """Application instance""" scopes: list[core_entities.LifecycleControlScope] """Task scope""" instance_uuid: str | None """Owning LangBot instance for a tenant user task.""" workspace_uuid: str | None """Owning Workspace for a tenant user task.""" placement_generation: int | None """Workspace execution fence captured when the task was created.""" def __init__( self, ap: app.Application, coro: typing.Coroutine, task_type: str = 'system', kind: str = 'system_task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, ): self.id = TaskWrapper._id_index TaskWrapper._id_index += 1 self.ap = ap self.task_context = context or TaskContext() self.task = create_detached_task( coro, loop=self.ap.event_loop, name=name or None, after_commit_manager=getattr(self.ap, 'persistence_mgr', None), workspace_uuid=workspace_uuid, ) self.task_type = task_type self.kind = kind self.name = name self.label = label if label != '' else name self.task.set_name(name) self.scopes = scopes self.instance_uuid = instance_uuid self.workspace_uuid = workspace_uuid self.placement_generation = placement_generation self.created_at = time.time() def assume_exception(self): try: exception = self.task.exception() if self.task_stack is None: self.task_stack = self.task.get_stack() return exception except Exception: return None def assume_result(self): try: return self.task.result() except Exception: return None def to_dict(self) -> dict: exception_traceback = None if self.assume_exception() is not None: exception_traceback = 'Traceback (most recent call last):\n' for frame in self.task_stack: exception_traceback += ( f' File "{frame.f_code.co_filename}", line {frame.f_lineno}, in {frame.f_code.co_name}\n' ) exception_traceback += f' {self.assume_exception().__str__()}\n' return { 'id': self.id, 'task_type': self.task_type, 'kind': self.kind, 'name': self.name, 'label': self.label, 'workspace_uuid': self.workspace_uuid, 'placement_generation': self.placement_generation, 'scopes': [scope.value for scope in self.scopes], 'created_at': self.created_at, 'task_context': self.task_context.to_dict(), 'runtime': { 'done': self.task.done(), 'state': self.task._state, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception_traceback': exception_traceback, 'result': self.assume_result() if self.assume_result() is not None else None, }, } def cancel(self): self.task.cancel() class AsyncTaskManager: """Save all asynchronous tasks in the app Include system-level and user-level (plugin installation, update, etc. initiated by users directly)""" ap: app.Application tasks: list[TaskWrapper] """All tasks""" def __init__(self, ap: app.Application): self.ap = ap self.tasks = [] def _task_log_limit(self) -> int: value = self.ap.instance_config.data.get('system', {}).get('task_retention', {}).get('max_log_chars', 200000) try: value = int(value) except (TypeError, ValueError): value = 200000 return max(value, 1) def _user_task_limit(self, name: str, default: int) -> int: value = self.ap.instance_config.data.get('system', {}).get('task_retention', {}).get(name, default) try: value = int(value) except (TypeError, ValueError): value = default return max(value, 1) def _admit_user_task(self, coro: typing.Coroutine, workspace_uuid: str | None) -> None: active_user_tasks = [ wrapper for wrapper in self.tasks if wrapper.task_type == 'user' and not wrapper.task.done() ] global_limit = self._user_task_limit('max_active_user_tasks', 256) if len(active_user_tasks) >= global_limit: coro.close() raise TaskCapacityError('The instance has too many active user operations') if workspace_uuid is None: return workspace_limit = self._user_task_limit('max_active_user_tasks_per_workspace', 8) active_workspace_tasks = sum(1 for wrapper in active_user_tasks if wrapper.workspace_uuid == workspace_uuid) if active_workspace_tasks >= workspace_limit: coro.close() raise TaskCapacityError('The Workspace has too many active user operations') def create_task( self, coro: typing.Coroutine, task_type: str = 'system', kind: str = 'system-task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, ) -> TaskWrapper: if context is None: context = TaskContext(max_log_chars=self._task_log_limit()) else: context.max_log_chars = self._task_log_limit() if len(context.log) > context.max_log_chars: context.log = context.log[-context.max_log_chars :] wrapper = TaskWrapper( self.ap, coro, task_type, kind, name, label, context, scopes, instance_uuid, workspace_uuid, placement_generation, ) self.tasks.append(wrapper) wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks()) self._prune_completed_tasks() return wrapper def create_user_task( self, coro: typing.Coroutine, kind: str = 'user-task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, ) -> TaskWrapper: self._admit_user_task(coro, workspace_uuid) return self.create_task( coro, 'user', kind, name, label, context, scopes, instance_uuid, workspace_uuid, placement_generation, ) async def wait_all(self): await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True) def get_all_tasks(self) -> list[TaskWrapper]: return self.tasks def get_tasks_dict( self, type: str = None, kind: str = None, *, instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, ) -> dict: return { 'tasks': [ t.to_dict() for t in self.tasks if (type is None or t.task_type == type) and (kind is None or t.kind == kind) and (instance_uuid is None or t.instance_uuid == instance_uuid) and (workspace_uuid is None or t.workspace_uuid == workspace_uuid) and (placement_generation is None or t.placement_generation == placement_generation) ], 'id_index': TaskWrapper._id_index, } def get_stats(self) -> dict: completed = sum(1 for t in self.tasks if t.task.done()) return { 'total': len(self.tasks), 'running': len(self.tasks) - completed, 'completed': completed, 'id_index': TaskWrapper._id_index, } def get_task_by_id( self, id: int, *, instance_uuid: str | None = None, workspace_uuid: str | None = None, placement_generation: int | None = None, ) -> TaskWrapper | None: for t in self.tasks: if ( t.id == id and (instance_uuid is None or t.instance_uuid == instance_uuid) and (workspace_uuid is None or t.workspace_uuid == workspace_uuid) and (placement_generation is None or t.placement_generation == placement_generation) ): return t return None def cancel_by_scope(self, scope: core_entities.LifecycleControlScope): for wrapper in self.tasks: if not wrapper.task.done() and scope in wrapper.scopes: wrapper.task.cancel() def cancel_task(self, task_id: int): for wrapper in self.tasks: if wrapper.id == task_id: if not wrapper.task.done(): wrapper.task.cancel() return def _prune_completed_tasks(self): completed_limit = ( self.ap.instance_config.data.get('system', {}) .get('task_retention', {}) .get( 'completed_limit', 200, ) ) try: completed_limit = int(completed_limit) except (TypeError, ValueError): completed_limit = 200 if completed_limit < 1: completed_limit = 1 completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()] overflow = len(completed_tasks) - completed_limit if overflow <= 0: return remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]} self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]