From 0e3e3bc945027bad12de99d828e637c10500b4ab Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Mon, 23 Feb 2026 21:18:29 -0600 Subject: [PATCH] Wire scheduler to API/UI and improve loop control and PR headlines - Pass scheduler instance to API router and UI for loop timestamps and force-run endpoints - Add interruptible waits and force_heartbeat/force_poll methods - Record last_run timestamps for all scheduler loops in KV store - Update press release headline examples with real client headlines Co-Authored-By: Claude Opus 4.6 --- cheddahbot/__main__.py | 7 +++-- cheddahbot/scheduler.py | 53 +++++++++++++++++++++++++++++++--- skills/press_release_prompt.md | 27 +++++++++-------- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/cheddahbot/__main__.py b/cheddahbot/__main__.py index b324ed1..fe538bc 100644 --- a/cheddahbot/__main__.py +++ b/cheddahbot/__main__.py @@ -122,6 +122,7 @@ def main(): log.warning("Notification bus not available: %s", e) # Scheduler (uses default agent) + scheduler = None try: from .scheduler import Scheduler @@ -132,7 +133,9 @@ def main(): log.warning("Scheduler not available: %s", e) log.info("Launching Gradio UI on %s:%s...", config.host, config.port) - blocks = create_ui(registry, config, default_llm, notification_bus=notification_bus) + blocks = create_ui( + registry, config, default_llm, notification_bus=notification_bus, scheduler=scheduler + ) # Build a parent FastAPI app so we can mount the dashboard alongside Gradio. # Inserting routes into blocks.app before launch() doesn't work because @@ -148,7 +151,7 @@ def main(): # Mount API endpoints from .api import create_api_router - api_router = create_api_router(config, db, registry) + api_router = create_api_router(config, db, registry, scheduler=scheduler) fastapi_app.include_router(api_router) log.info("API router mounted at /api/") diff --git a/cheddahbot/scheduler.py b/cheddahbot/scheduler.py index f39dc04..a27cbd3 100644 --- a/cheddahbot/scheduler.py +++ b/cheddahbot/scheduler.py @@ -49,6 +49,8 @@ class Scheduler: self.agent = agent self.notification_bus = notification_bus self._stop_event = threading.Event() + self._force_heartbeat = threading.Event() + self._force_poll = threading.Event() self._thread: threading.Thread | None = None self._heartbeat_thread: threading.Thread | None = None self._clickup_thread: threading.Thread | None = None @@ -111,15 +113,49 @@ class Scheduler: else: log.info("Notification [%s]: %s", category, message) + # ── Loop control ── + + def _interruptible_wait(self, seconds: float, force_event: threading.Event | None = None): + """Wait for *seconds*, returning early if stop or force event fires.""" + remaining = seconds + while remaining > 0 and not self._stop_event.is_set(): + if force_event and force_event.is_set(): + force_event.clear() + return + self._stop_event.wait(min(5, remaining)) + remaining -= 5 + + def force_heartbeat(self): + """Wake the heartbeat loop immediately.""" + self._force_heartbeat.set() + + def force_poll(self): + """Wake the scheduler poll loop immediately.""" + self._force_poll.set() + + def get_loop_timestamps(self) -> dict[str, str | None]: + """Return last_run timestamps for all loops.""" + return { + "heartbeat": self.db.kv_get("system:loop:heartbeat:last_run"), + "poll": self.db.kv_get("system:loop:poll:last_run"), + "clickup": self.db.kv_get("system:loop:clickup:last_run"), + "folder_watch": self.db.kv_get("system:loop:folder_watch:last_run"), + } + # ── Scheduled Tasks ── def _poll_loop(self): while not self._stop_event.is_set(): try: self._run_due_tasks() + self.db.kv_set( + "system:loop:poll:last_run", datetime.now(UTC).isoformat() + ) except Exception as e: log.error("Scheduler poll error: %s", e) - self._stop_event.wait(self.config.scheduler.poll_interval_seconds) + self._interruptible_wait( + self.config.scheduler.poll_interval_seconds, self._force_poll + ) def _run_due_tasks(self): tasks = self.db.get_due_tasks() @@ -154,9 +190,12 @@ class Scheduler: while not self._stop_event.is_set(): try: self._run_heartbeat() + self.db.kv_set( + "system:loop:heartbeat:last_run", datetime.now(UTC).isoformat() + ) except Exception as e: log.error("Heartbeat error: %s", e) - self._stop_event.wait(interval) + self._interruptible_wait(interval, self._force_heartbeat) def _run_heartbeat(self): heartbeat_path = self.config.identity_dir / "HEARTBEAT.md" @@ -201,9 +240,12 @@ class Scheduler: while not self._stop_event.is_set(): try: self._poll_clickup() + self.db.kv_set( + "system:loop:clickup:last_run", datetime.now(UTC).isoformat() + ) except Exception as e: log.error("ClickUp poll error: %s", e) - self._stop_event.wait(interval) + self._interruptible_wait(interval) def _discover_field_filter(self, client): """Discover and cache the Work Category field UUID + option map.""" @@ -468,9 +510,12 @@ class Scheduler: while not self._stop_event.is_set(): try: self._scan_watch_folder() + self.db.kv_set( + "system:loop:folder_watch:last_run", datetime.now(UTC).isoformat() + ) except Exception as e: log.error("Folder watcher error: %s", e) - self._stop_event.wait(interval) + self._interruptible_wait(interval) def _scan_watch_folder(self): """Scan the watch folder for new .xlsx files and match to ClickUp tasks.""" diff --git a/skills/press_release_prompt.md b/skills/press_release_prompt.md index f5c1c26..7f5519e 100644 --- a/skills/press_release_prompt.md +++ b/skills/press_release_prompt.md @@ -20,8 +20,20 @@ When the user provides a press release topic, follow this workflow: - Title case - News-focused (not promotional) - Free of location keywords, superlatives (best/top/leading/#1), and questions - - Contains actual news announcement + - Not make up information that isn't true. - Present all 7 titles to an AI agent to judge which is best. This can be decided by looking at titles on Press Advantage for other businesses, and seeing how closely the headline follows the instructions. + + ** EXAMPLE GREAT HEADLINES: ** + - Dietz Electric Highlights Flameproof Motor Safety Options + - MOD-TRONIC Reaffirms Position as Largest MINCO Stocking Distributor + - Hogge Precision Parts Delivers Precision Machining for the Medical Industry + - Lubrication Engineers Drives Awareness of Fuel Treatment Benefits for Year-Round Fleet Efficiency + - Renown Electric Champions Proactive Downtime Protection With Contingency Planning Insights + - MCM Composites Releases Enhanced Thermoset Comparison Resource + - AGI Fabricators Publishes New Resource on Custom Process Hopper Fabrication + - Paragon Steel Strengthens Support For Central Los Angeles Commercial Projects + - McCormick Industries Reinforces Quality Standards With ISO 9001:2015-Certified Medical Machining + 2. **Gather Any Additional Required Information**: - If the user provides LSI terms explicitly, use them @@ -58,19 +70,6 @@ When generating the 7 headline options: - No promotional language (e.g., "Revolutionary," "Game-Changing") - Focus on the news, not the hype -**Examples of Good Headlines**: -- "TechCorp Launches AI-Powered Customer Service Platform" (56 chars) -- "Green Solutions Secures $50M Series B Funding Round" (52 chars) -- "Acme Industries Expands Operations to European Markets" (55 chars) -- "DataFlow Announces Strategic Partnership with IBM" (50 chars) -- "HealthTech Achieves ISO 27001 Certification" (44 chars) -- Also check the headlines.md file (if it exists) for other examples of good headlines. - -**Examples of Bad Headlines** (DO NOT USE): -- ❌ "Is Your Business Ready for AI Customer Service?" (question) -- ❌ "Chicago's Leading TechCorp Launches New Platform" (location + superlative) -- ❌ "Best-in-Class AI Solution Revolutionizes Support" (superlative + hype) -- ❌ "TechCorp: The #1 Choice for Customer Service AI" (superlative + promotional) ## Critical Press Advantage Requirements