142 lines
6.4 KiB
Python
142 lines
6.4 KiB
Python
"""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. "
|
|
"Check your own recent posts in the history first: pick a subject you have not touched lately and a different form "
|
|
"than last time, and never open with a fixed label or heading — just start mid-thought."
|
|
)
|
|
|
|
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)
|