feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments
Core agent improvements: - RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector - Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection - Rich conversation storage for notable turns; compact_conversation truncates long user messages - Task-type classifier (query/action/analysis/creative) for observation tagging - Nested sub-agent visibility: deep delegations now register against the main agent's manager Child safety (Gabriel profile): - child_safety.py: filtering, audit logging, prompt constants for restricted sessions - .kiro/specs/child-safety-profile: requirements, design, tasks specs - GABRIEL_BOT_PROPOSAL.md: initial proposal doc - Reduced context window (10 msgs) and tutor-mode identity for restricted users Telegram adapter: - Polling watchdog: auto-restarts updater if polling drops unexpectedly - get_me() with exponential-backoff retry on NetworkError at startup - Correct stop() ordering: signal watchdog before cancelling tasks Email / Gmail: - send_email: supports file attachments (attachments list param) - get_email: surfaces attachment metadata in response Scheduled tasks / weather: - Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively - New scheduled tasks and scheduler state persistence Discord: - adapters/discord/__init__.py scaffold - discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config) Infrastructure: - n8n workflow exports (garvis_webhook, content_pipeline variants) - memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs - UCS C240 migration plan doc - requirements.txt: new deps - .claude/settings.json, fix_hooks.py: hook/permission tuning
This commit is contained in:
@@ -12,6 +12,7 @@ Example use cases:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
@@ -82,6 +83,9 @@ class TaskScheduler:
|
||||
# Track file modification time for auto-reload
|
||||
self._last_mtime: Optional[float] = None
|
||||
|
||||
# Persistent state: survives restarts so we can recover missed tasks
|
||||
self._state_file = self.config_file.parent / "scheduler_state.json"
|
||||
|
||||
self._load_tasks()
|
||||
|
||||
def _load_tasks(self) -> None:
|
||||
@@ -111,6 +115,7 @@ class TaskScheduler:
|
||||
self.tasks.append(task)
|
||||
|
||||
print(f"[Scheduler] Loaded {len(self.tasks)} task(s)")
|
||||
self._load_state()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default scheduled tasks config."""
|
||||
@@ -233,6 +238,101 @@ class TaskScheduler:
|
||||
|
||||
return True
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Restore last_run times from disk so missed-task recovery works after restart."""
|
||||
if not self._state_file.exists():
|
||||
return
|
||||
try:
|
||||
state = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||
for task in self.tasks:
|
||||
if task.name in state:
|
||||
task.last_run = datetime.fromisoformat(state[task.name])
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Could not load state: {e}")
|
||||
|
||||
def _save_state(self) -> None:
|
||||
"""Persist last_run times to disk."""
|
||||
try:
|
||||
state = {
|
||||
t.name: t.last_run.isoformat()
|
||||
for t in self.tasks
|
||||
if t.last_run
|
||||
}
|
||||
self._state_file.write_text(
|
||||
json.dumps(state, indent=2), encoding="utf-8"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Could not save state: {e}")
|
||||
|
||||
def _last_scheduled_time(self, schedule: str) -> Optional[datetime]:
|
||||
"""Return the most recent past occurrence of this schedule."""
|
||||
now = datetime.now()
|
||||
parts = schedule.lower().split()
|
||||
|
||||
if parts[0] == "daily" and len(parts) >= 2:
|
||||
hour, minute = map(int, parts[1].split(":"))
|
||||
t = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if t > now:
|
||||
t -= timedelta(days=1)
|
||||
return t
|
||||
|
||||
if parts[0] == "weekly" and len(parts) >= 3:
|
||||
target_day = _DAY_NAMES.index(parts[1])
|
||||
hour, minute = map(int, parts[2].split(":"))
|
||||
days_back = (now.weekday() - target_day) % 7
|
||||
t = (now - timedelta(days=days_back)).replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
if t > now:
|
||||
t -= timedelta(days=7)
|
||||
return t
|
||||
|
||||
return None
|
||||
|
||||
def _check_missed_tasks(self) -> None:
|
||||
"""On startup, immediately run any task that was missed while the bot was down.
|
||||
|
||||
Recovery windows:
|
||||
weekly → 24 hours (bot could be down overnight on the scheduled day)
|
||||
daily → 2 hours (short window; don't replay stale morning briefings)
|
||||
hourly → skipped (not worth recovering)
|
||||
"""
|
||||
now = datetime.now()
|
||||
for task in self.tasks:
|
||||
if not task.enabled:
|
||||
continue
|
||||
|
||||
schedule_type = task.schedule.lower().split()[0]
|
||||
if schedule_type == "hourly":
|
||||
continue
|
||||
|
||||
recovery_window = (
|
||||
timedelta(hours=24) if schedule_type == "weekly"
|
||||
else timedelta(hours=2)
|
||||
)
|
||||
|
||||
last_scheduled = self._last_scheduled_time(task.schedule)
|
||||
if last_scheduled is None:
|
||||
continue
|
||||
|
||||
# Was the scheduled time within the recovery window?
|
||||
if now - last_scheduled > recovery_window:
|
||||
continue
|
||||
|
||||
# Did the task already run since the scheduled time?
|
||||
if task.last_run and task.last_run >= last_scheduled:
|
||||
continue
|
||||
|
||||
print(
|
||||
f"[Scheduler] Recovering missed task: {task.name} "
|
||||
f"(was due {last_scheduled.strftime('%Y-%m-%d %H:%M')})"
|
||||
)
|
||||
threading.Thread(
|
||||
target=self._execute_task,
|
||||
args=(task,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def add_adapter(self, platform: str, adapter: Any) -> None:
|
||||
"""Register an adapter for sending task outputs."""
|
||||
self.adapters[platform] = adapter
|
||||
@@ -249,6 +349,9 @@ class TaskScheduler:
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
# Recover any tasks missed while the bot was down before continuing
|
||||
self._check_missed_tasks()
|
||||
|
||||
print(f"[Scheduler] Started with {len(self.tasks)} task(s)")
|
||||
for task in self.tasks:
|
||||
if task.enabled and task.next_run:
|
||||
@@ -323,6 +426,9 @@ class TaskScheduler:
|
||||
print(f"[Scheduler] Task completed: {task.name}")
|
||||
print(f" Response: {response[:100]}...")
|
||||
|
||||
# Persist last_run so missed-task recovery works after restarts
|
||||
self._save_state()
|
||||
|
||||
if task.send_to_platform and task.send_to_channel:
|
||||
# Use the running event loop if available, otherwise create one.
|
||||
# asyncio.run() fails if an event loop is already running
|
||||
|
||||
Reference in New Issue
Block a user