cascade-orchestrator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. cascade/__init__.py +8 -0
  2. cascade/config.py +48 -0
  3. cascade/database.py +69 -0
  4. cascade/engine/__init__.py +1 -0
  5. cascade/engine/loop.py +105 -0
  6. cascade/engine/pinger.py +39 -0
  7. cascade/engine/poller.py +38 -0
  8. cascade/engine/progress_tracker.py +63 -0
  9. cascade/integrations/__init__.py +33 -0
  10. cascade/integrations/hermes_bridge.py +616 -0
  11. cascade/integrations/monitor_daemon.py +357 -0
  12. cascade/main.py +122 -0
  13. cascade/mcp/__init__.py +5 -0
  14. cascade/mcp/instructions.py +34 -0
  15. cascade/mcp/server.py +74 -0
  16. cascade/mcp/tools.py +230 -0
  17. cascade/models/__init__.py +26 -0
  18. cascade/models/dependency.py +44 -0
  19. cascade/models/event.py +58 -0
  20. cascade/models/goal.py +46 -0
  21. cascade/models/message.py +38 -0
  22. cascade/models/milestone.py +43 -0
  23. cascade/models/project.py +57 -0
  24. cascade/models/task.py +144 -0
  25. cascade/models/telemetry.py +34 -0
  26. cascade/routers/__init__.py +21 -0
  27. cascade/routers/dashboard.py +149 -0
  28. cascade/routers/events.py +61 -0
  29. cascade/routers/goals.py +70 -0
  30. cascade/routers/milestones.py +60 -0
  31. cascade/routers/pages.py +154 -0
  32. cascade/routers/projects.py +69 -0
  33. cascade/routers/tasks.py +204 -0
  34. cascade/schemas/__init__.py +51 -0
  35. cascade/schemas/event.py +50 -0
  36. cascade/schemas/goal.py +57 -0
  37. cascade/schemas/message.py +31 -0
  38. cascade/schemas/milestone.py +42 -0
  39. cascade/schemas/project.py +34 -0
  40. cascade/schemas/task.py +92 -0
  41. cascade/services/__init__.py +25 -0
  42. cascade/services/auto_decision.py +116 -0
  43. cascade/services/event_service.py +104 -0
  44. cascade/services/goal_service.py +130 -0
  45. cascade/services/milestone_service.py +125 -0
  46. cascade/services/monitor_service.py +182 -0
  47. cascade/services/project_service.py +111 -0
  48. cascade/services/scheduler_service.py +124 -0
  49. cascade/services/task_service.py +478 -0
  50. cascade/utils.py +28 -0
  51. cascade/web/static/app.js +50 -0
  52. cascade/web/templates/base.html +50 -0
  53. cascade/web/templates/board.html +55 -0
  54. cascade/web/templates/dashboard.html +98 -0
  55. cascade/web/templates/partials/goal_card.html +14 -0
  56. cascade/web/templates/partials/progress_bar.html +12 -0
  57. cascade/web/templates/partials/project_aggregate.html +41 -0
  58. cascade/web/templates/partials/task_card.html +24 -0
  59. cascade/web/templates/partials/task_list.html +15 -0
  60. cascade/web/templates/project.html +113 -0
  61. cascade/web/templates/task.html +157 -0
  62. cascade_orchestrator-0.1.0.dist-info/METADATA +290 -0
  63. cascade_orchestrator-0.1.0.dist-info/RECORD +67 -0
  64. cascade_orchestrator-0.1.0.dist-info/WHEEL +5 -0
  65. cascade_orchestrator-0.1.0.dist-info/entry_points.txt +3 -0
  66. cascade_orchestrator-0.1.0.dist-info/licenses/LICENSE +21 -0
  67. cascade_orchestrator-0.1.0.dist-info/top_level.txt +1 -0
cascade/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Cascade — agent task orchestration platform.
2
+
3
+ Combines Leantime's strategic coherence (task-goal-milestone links) with
4
+ AgentRQ's agent orchestration (dequeue, status state machine, MCP tools,
5
+ continuous monitoring loop).
6
+ """
7
+
8
+ __version__ = "0.1.0"
cascade/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """Application configuration via Pydantic Settings.
2
+
3
+ All settings can be overridden with environment variables prefixed ``CASCADE_``
4
+ (e.g. ``CASCADE_PORT=8100``) or via a ``.env`` file in the working directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ """Central configuration for the Cascade platform."""
14
+
15
+ model_config = SettingsConfigDict(
16
+ env_prefix="CASCADE_",
17
+ env_file=".env",
18
+ env_file_encoding="utf-8",
19
+ extra="ignore",
20
+ )
21
+
22
+ # --- Database ---
23
+ database_url: str = "sqlite+aiosqlite:///./cascade.db"
24
+
25
+ # --- Server ---
26
+ host: str = "0.0.0.0"
27
+ port: int = 8100
28
+ reload: bool = True
29
+
30
+ # --- Engine ---
31
+ # Seconds between monitoring-loop ticks. Continuous monitoring, not hourly.
32
+ loop_tick_seconds: int = 10
33
+ # Seconds between stall-detection sweeps.
34
+ stall_check_interval_seconds: int = 300
35
+ # Minutes of silence before an ongoing task is considered stalled.
36
+ stall_threshold_minutes: int = 30
37
+ # Seconds before an agent session with no heartbeat is considered dead.
38
+ session_timeout_seconds: int = 60
39
+
40
+ # --- Scheduling ---
41
+ scheduler_interval_seconds: int = 60
42
+
43
+ # --- Feature flags ---
44
+ enable_monitoring_loop: bool = True
45
+ enable_scheduler: bool = True
46
+
47
+
48
+ settings = Settings()
cascade/database.py ADDED
@@ -0,0 +1,69 @@
1
+ """Async database engine, session factory and declarative Base.
2
+
3
+ Provides the single :data:`engine`, the :data:`async_session_factory` and the
4
+ dependency :func:`get_db` used by FastAPI route handlers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncGenerator
10
+
11
+ from sqlalchemy import event
12
+ from sqlalchemy.ext.asyncio import (
13
+ AsyncSession,
14
+ async_sessionmaker,
15
+ create_async_engine,
16
+ )
17
+ from sqlalchemy.orm import DeclarativeBase
18
+
19
+ from cascade.config import settings
20
+
21
+
22
+ class Base(DeclarativeBase):
23
+ """Declarative base for all SQLAlchemy 2.0 typed models."""
24
+
25
+
26
+ engine = create_async_engine(
27
+ settings.database_url,
28
+ echo=False,
29
+ future=True,
30
+ )
31
+
32
+ if engine.url.get_backend_name() == "sqlite":
33
+ # The monitoring loop and API requests hit SQLite from several concurrent
34
+ # connections. Without WAL + a busy timeout, SQLite's default rollback
35
+ # journal serialises writers and raises "database is locked" almost
36
+ # immediately under any real concurrency.
37
+ @event.listens_for(engine.sync_engine, "connect")
38
+ def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None: # noqa: ANN001
39
+ cursor = dbapi_connection.cursor()
40
+ cursor.execute("PRAGMA journal_mode=WAL")
41
+ cursor.execute("PRAGMA busy_timeout=30000")
42
+ cursor.execute("PRAGMA foreign_keys=ON")
43
+ cursor.close()
44
+
45
+ async_session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
46
+ bind=engine,
47
+ class_=AsyncSession,
48
+ expire_on_commit=False,
49
+ autoflush=False,
50
+ )
51
+
52
+
53
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
54
+ """FastAPI dependency that yields an :class:`AsyncSession`."""
55
+ async with async_session_factory() as session:
56
+ try:
57
+ yield session
58
+ except Exception:
59
+ await session.rollback()
60
+ raise
61
+
62
+
63
+ async def init_db() -> None:
64
+ """Create all tables. Intended for dev/bootstrap; Alembic is used in prod."""
65
+ # Importing here ensures every model is registered on ``Base.metadata``.
66
+ from cascade import models # noqa: F401 (register models)
67
+
68
+ async with engine.begin() as conn:
69
+ await conn.run_sync(Base.metadata.create_all)
@@ -0,0 +1 @@
1
+ """Engine: continuous monitoring loop, polling, pinger and progress tracking."""
cascade/engine/loop.py ADDED
@@ -0,0 +1,105 @@
1
+ """Main monitoring loop — continuous execution (10s tick, NOT hourly).
2
+
3
+ Runs forever (as a background task on app startup) and, on every tick, runs
4
+ the poller, pinger and scheduler concurrently. Stall detection runs on its own
5
+ slower cadence. This is the AgentRQ loop, reimagined as continuous monitoring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import time
13
+
14
+ from cascade.config import settings
15
+ from cascade.database import async_session_factory
16
+ from cascade.engine.pinger import pinger_tick
17
+ from cascade.engine.poller import poller_tick
18
+ from cascade.services.scheduler_service import SchedulerService
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Module-level state so the loop can be stopped gracefully.
23
+ _running = False
24
+ _last_stall_check = 0.0
25
+
26
+
27
+ async def scheduler_tick() -> int:
28
+ """Process due cron templates, returning spawned task count."""
29
+ async with async_session_factory() as session:
30
+ svc = SchedulerService(session)
31
+ spawned = await svc.process_cron_templates()
32
+ if spawned:
33
+ logger.info("Scheduler spawned %d cron task(s)", len(spawned))
34
+ return len(spawned)
35
+
36
+
37
+ async def stall_detector_tick() -> int:
38
+ """Run stall detection on its own slower cadence (every 5 min)."""
39
+ global _last_stall_check
40
+ now = time.time()
41
+ if now - _last_stall_check < settings.stall_check_interval_seconds:
42
+ return 0
43
+ _last_stall_check = now
44
+ async with async_session_factory() as session:
45
+ return await poller_tick(session)
46
+
47
+
48
+ async def _poller_tick_isolated() -> int:
49
+ """Run the poller in its own session (never share one across concurrent ticks)."""
50
+ async with async_session_factory() as session:
51
+ result = await poller_tick(session)
52
+ await session.commit()
53
+ return result
54
+
55
+
56
+ async def _pinger_tick_isolated() -> int:
57
+ """Run the pinger in its own session (never share one across concurrent ticks)."""
58
+ async with async_session_factory() as session:
59
+ result = await pinger_tick(session)
60
+ await session.commit()
61
+ return result
62
+
63
+
64
+ async def _tick() -> None:
65
+ """Run one monitoring tick: poller + pinger + scheduler concurrently.
66
+
67
+ Each concurrent piece of work gets its own :class:`AsyncSession` —
68
+ ``AsyncSession`` is not safe for concurrent use by multiple coroutines,
69
+ so sharing one across ``asyncio.gather`` would corrupt session state.
70
+ """
71
+ results = await asyncio.gather(
72
+ _poller_tick_isolated(),
73
+ _pinger_tick_isolated(),
74
+ scheduler_tick(),
75
+ stall_detector_tick(),
76
+ return_exceptions=True,
77
+ )
78
+ for name, result in zip(
79
+ ("poller", "pinger", "scheduler", "stall_detector"), results
80
+ ):
81
+ if isinstance(result, BaseException):
82
+ logger.error("Monitoring tick component %r failed: %s", name, result)
83
+
84
+
85
+ async def monitoring_loop() -> None:
86
+ """Main loop that runs forever, monitoring all projects every 10s."""
87
+ global _running
88
+ _running = True
89
+ logger.info("Cascade monitoring loop started (tick=%ss)", settings.loop_tick_seconds)
90
+ while _running:
91
+ try:
92
+ await _tick()
93
+ except Exception: # pragma: no cover - never let the loop die
94
+ logger.exception("Monitoring tick failed")
95
+ await asyncio.sleep(settings.loop_tick_seconds)
96
+ logger.info("Cascade monitoring loop stopped")
97
+
98
+
99
+ def stop_monitoring_loop() -> None:
100
+ """Signal the monitoring loop to stop after the current tick."""
101
+ global _running
102
+ _running = False
103
+
104
+
105
+ __all__ = ["monitoring_loop", "stop_monitoring_loop", "scheduler_tick"]
@@ -0,0 +1,39 @@
1
+ """Pinger — session keep-alive + dead-session eviction.
2
+
3
+ Runs on the monitoring loop to evict agent sessions whose last heartbeat
4
+ exceeds the configured timeout, so the dashboard's "agent alive" indicator
5
+ stays accurate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from cascade.engine.progress_tracker import tracker
15
+ from cascade.services.monitor_service import MonitorService
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def pinger_tick(session: AsyncSession) -> int:
21
+ """Evict dead agent sessions and broadcast status changes via SSE.
22
+
23
+ Returns the number of sessions evicted.
24
+ """
25
+ monitor_svc = MonitorService(session)
26
+ # Snapshot BEFORE evicting: a project whose last session just died is
27
+ # removed from ``_sessions`` entirely, so iterating the dict *after*
28
+ # eviction would silently skip broadcasting its now-offline status.
29
+ watched_projects = set(monitor_svc._sessions) # noqa: SLF001
30
+ evicted = await monitor_svc.evict_dead_sessions()
31
+ if evicted:
32
+ logger.info("Evicted %d dead agent session(s)", evicted)
33
+ # Notify any dashboard subscribers that liveness may have changed.
34
+ for project_id in watched_projects:
35
+ alive = await monitor_svc.is_agent_alive(project_id)
36
+ await tracker.publish(
37
+ project_id, "agent_status", {"alive": alive, "evicted": evicted}
38
+ )
39
+ return evicted
@@ -0,0 +1,38 @@
1
+ """Poller — work distribution + stall nudging.
2
+
3
+ In Cascade, agents pull work via ``get_task`` (dequeue). The poller therefore
4
+ focuses on *push* opportunities that a pull-only model misses: detecting
5
+ stalled tasks and nudging them so work never silently stalls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from cascade.services.monitor_service import MonitorService
15
+ from cascade.services.project_service import ProjectService
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def poller_tick(session: AsyncSession) -> int:
21
+ """Detect stalled tasks across all projects and nudge them.
22
+
23
+ Returns the number of tasks nudged.
24
+ """
25
+ project_svc = ProjectService(session)
26
+ monitor_svc = MonitorService(session)
27
+ projects = await project_svc.list_projects(status="active")
28
+
29
+ nudged = 0
30
+ for project in projects:
31
+ stalled = await monitor_svc.get_stalled_tasks(project.id)
32
+ for task in stalled:
33
+ await monitor_svc.nudge_stalled_task(task.id)
34
+ nudged += 1
35
+ logger.info("Nudged stalled task %s in project %s", task.id, project.id)
36
+ if nudged:
37
+ await session.commit()
38
+ return nudged
@@ -0,0 +1,63 @@
1
+ """Real-time progress aggregation + SSE broadcasting broker.
2
+
3
+ An in-memory pub/sub keyed by project. SSE clients subscribe to a project's
4
+ queue via :meth:`ProgressTracker.subscribe`; services publish events via
5
+ :meth:`ProgressTracker.publish` which fans out to every subscriber.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections import defaultdict
13
+ from collections.abc import AsyncGenerator
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ProgressTracker:
20
+ """In-memory broadcast broker for Server-Sent Events."""
21
+
22
+ def __init__(self) -> None:
23
+ # project_id -> list of asyncio.Queue
24
+ self._subscribers: dict[str, list[asyncio.Queue[dict[str, Any]]]] = defaultdict(list)
25
+
26
+ def subscribe(self, project_id: str) -> asyncio.Queue[dict[str, Any]]:
27
+ """Register a new subscriber queue for ``project_id``."""
28
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
29
+ self._subscribers[project_id].append(queue)
30
+ logger.debug("SSE subscriber added for project %s", project_id)
31
+ return queue
32
+
33
+ def unsubscribe(self, project_id: str, queue: asyncio.Queue[dict[str, Any]]) -> None:
34
+ """Remove a previously-registered subscriber queue."""
35
+ if project_id in self._subscribers:
36
+ try:
37
+ self._subscribers[project_id].remove(queue)
38
+ except ValueError:
39
+ pass
40
+ if not self._subscribers[project_id]:
41
+ del self._subscribers[project_id]
42
+
43
+ async def publish(self, project_id: str, event_type: str, data: dict[str, Any]) -> None:
44
+ """Broadcast an event to all subscribers of ``project_id``."""
45
+ message = {"event": event_type, "data": {**data, "project_id": project_id}}
46
+ for queue in list(self._subscribers.get(project_id, [])):
47
+ try:
48
+ queue.put_nowait(message)
49
+ except asyncio.QueueFull:
50
+ logger.warning("SSE queue full for project %s; dropping event", project_id)
51
+
52
+ async def stream(self, project_id: str) -> AsyncGenerator[dict[str, Any], None]:
53
+ """Yield events for ``project_id`` until the subscriber disconnects."""
54
+ queue = self.subscribe(project_id)
55
+ try:
56
+ while True:
57
+ yield await queue.get()
58
+ finally:
59
+ self.unsubscribe(project_id, queue)
60
+
61
+
62
+ # Module-level singleton used across services and routers.
63
+ tracker = ProgressTracker()
@@ -0,0 +1,33 @@
1
+ """Cascade integration layer.
2
+
3
+ Bridges external systems (notably the Hermes agent) to the Cascade task
4
+ orchestration platform. The async REST client is always importable; the
5
+ monitor daemon is imported lazily so that consumers who only need the client
6
+ do not pay for its heavier runtime setup.
7
+
8
+ .. code-block:: python
9
+
10
+ from cascade.integrations import CascadeClient
11
+
12
+ async with CascadeClient("http://localhost:8100") as client:
13
+ project = await client.create_project(name="Ship feature X")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from cascade.integrations.hermes_bridge import (
19
+ CascadeAPIError,
20
+ CascadeClient,
21
+ DotDict,
22
+ )
23
+
24
+ __all__ = ["CascadeClient", "CascadeAPIError", "DotDict", "MonitorDaemon"]
25
+
26
+
27
+ def __getattr__(name: str):
28
+ """Lazy-load the monitor daemon to keep the client import lightweight."""
29
+ if name == "MonitorDaemon":
30
+ from cascade.integrations.monitor_daemon import MonitorDaemon
31
+
32
+ return MonitorDaemon
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")