"""Self-tasking engine (SPEC-005, FDB-011). Generators propose, the scheduler executes — through the injected execute callback (= the normal responder path), so budget, gates and kill-switches all apply. Everything is off unless `tasks-enabled` is true (TSK-03). """ import asyncio import logging import time from typing import Any, Awaitable, Callable, Dict, List, Optional from .persistence import PersistentStore from .quota import QuotaLedger DEFAULT_MAX_PER_CHANNEL_PER_DAY = 2 DEFAULT_IDLE_IMPULSE_HOURS = 12.0 DEFAULT_TASKGEN_INTERVAL_HOURS = 6.0 DEFAULT_BORENESS_PROMPT = ( "A thought just occurred to you. Anchor it to something real you know — recent news (use get_news), a game " "releasing soon, the weather, or a regular you remember — not a generic musing. Share it briefly, in your own " "voice, as an observation, a gentle question, or a joke; never an advertisement. Read the room and stay in character." ) ExecuteCallback = Callable[[str, str], Awaitable[None]] ProposeCallback = Callable[[], Awaitable[Optional[Dict[str, Any]]]] AlertCallback = Callable[[str], Awaitable[None]] class TaskEngine: def __init__( self, store: Optional[PersistentStore], ledger: QuotaLedger, config_getter: Callable[[], Dict[str, Any]], execute: ExecuteCallback, propose: ProposeCallback, staff_alert: AlertCallback, allowed: Callable[[], bool], idle_seconds: Callable[[], float], observe: Callable[[str, str, str], Awaitable[None]], ) -> None: self.store = store self.ledger = ledger self._config = config_getter self._execute = execute self._propose = propose self._staff_alert = staff_alert self._allowed = allowed self._idle_seconds = idle_seconds self._observe = observe self._last_generation = 0.0 self._lock = asyncio.Lock() def active(self) -> bool: return self.store is not None and bool(self._config().get("tasks-enabled", False)) # TSK-03 def generators(self) -> List[str]: return list(self._config().get("tasks-generators", ["idle-impulse", "follow-up"])) async def enqueue(self, kind: str, channel: str, payload: str, due_hours: float = 0.0) -> int: assert self.store is not None state = "approval" if self._config().get("tasks-approval", False) else "queued" # TSK-05 due_at = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + due_hours * 3600.0)) task_id = int(await asyncio.to_thread(self.store.task_add, kind, channel, due_at, payload, state) or 0) if state == "approval": await self._staff_alert(f"Task #{task_id} proposed ({kind}, #{channel}): {payload[:180]} — !bot task-approve {task_id}") return task_id def _channel_cap_ok(self, channel: str) -> bool: cap = int(self._config().get("tasks-max-per-channel-per-day", DEFAULT_MAX_PER_CHANNEL_PER_DAY)) return self.ledger._get(f"task-runs:{channel}") < cap # TSK-04 async def tick(self) -> None: """One scheduler pass: execute due tasks, then maybe generate (TSK-02/06).""" if not self.active() or not self._allowed() or self._lock.locked(): return assert self.store is not None async with self._lock: await self._maybe_generate() for task in await asyncio.to_thread(self.store.tasks_due): if not self._channel_cap_ok(task["channel"]): continue # stays queued for tomorrow (TSK-04) try: await self._execute(task["channel"], task["payload"]) await asyncio.to_thread(self.store.task_set_state, task["id"], "done") self.ledger._add(f"task-runs:{task['channel']}", 1) await self._observe("system", "task", f"executed {task['kind']}: {task['payload'][:200]}") except Exception as err: logging.warning(f"task {task['id']} failed: {repr(err)}") await asyncio.to_thread(self.store.task_set_state, task["id"], "failed") async def _maybe_generate(self) -> None: config = self._config() interval = float(config.get("taskgen-interval-hours", DEFAULT_TASKGEN_INTERVAL_HOURS)) * 3600.0 now = time.monotonic() if self._last_generation and now - self._last_generation < interval: return self._last_generation = now generators = self.generators() if "idle-impulse" in generators: await self._generate_idle_impulse() if "follow-up" in generators: await self._generate_follow_up() async def _generate_idle_impulse(self) -> None: """Boreness, demoted to a deterministic generator (TSK-07).""" assert self.store is not None config = self._config() channel = config.get("chat-channel") if not channel: return idle_threshold = float(config.get("idle-impulse-hours", DEFAULT_IDLE_IMPULSE_HOURS)) * 3600.0 if self._idle_seconds() < idle_threshold: return if await asyncio.to_thread(self.store.tasks_pending_of_kind, "idle-impulse"): return # one pending impulse is enough prompt = config.get("boreness-prompt", DEFAULT_BORENESS_PROMPT) await self.enqueue("idle-impulse", channel, prompt) async def _generate_follow_up(self) -> None: """Ask the model for one follow-up worth doing (TSK-08).""" assert self.store is not None if await asyncio.to_thread(self.store.tasks_pending_of_kind, "follow-up"): return proposal = await self._propose() if not proposal or not isinstance(proposal.get("task"), dict): return task = proposal["task"] channel = str(task.get("channel") or self._config().get("chat-channel") or "") prompt = str(task.get("prompt") or "").strip() if not channel or not prompt: return try: due_hours = max(0.0, float(task.get("due_hours") or 0.0)) except (TypeError, ValueError): due_hours = 0.0 await self.enqueue("follow-up", channel, prompt, due_hours)