self-tasking engine: persistent queue, idle-impulse + follow-up generators, approval mode
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""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 = "Pretend that you just now thought of something, be creative."
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user