draft-board 0.1.0-beta.0
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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""API router for Planner endpoints."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from sqlalchemy import and_, func, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
|
|
13
|
+
from app.database import get_db
|
|
14
|
+
from app.models.ticket_event import TicketEvent
|
|
15
|
+
from app.schemas.planner import (
|
|
16
|
+
PlannerAction,
|
|
17
|
+
PlannerStartRequest,
|
|
18
|
+
PlannerStartResponse,
|
|
19
|
+
PlannerTickRequest,
|
|
20
|
+
PlannerTickResponse,
|
|
21
|
+
)
|
|
22
|
+
from app.services.config_service import ConfigService
|
|
23
|
+
from app.services.planner_service import PlannerLockError, PlannerService
|
|
24
|
+
from app.state_machine import ActorType
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
router = APIRouter(prefix="/planner", tags=["planner"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PlannerFeaturesStatus(BaseModel):
|
|
32
|
+
"""Status of planner feature flags."""
|
|
33
|
+
|
|
34
|
+
auto_execute: bool
|
|
35
|
+
propose_followups: bool
|
|
36
|
+
generate_reflections: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LastTickStats(BaseModel):
|
|
40
|
+
"""Statistics from the most recent planner tick."""
|
|
41
|
+
|
|
42
|
+
executed: int
|
|
43
|
+
followups_created: int
|
|
44
|
+
reflections_added: int
|
|
45
|
+
last_tick_at: datetime | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LLMHealthCheck(BaseModel):
|
|
49
|
+
"""Result of LLM health check."""
|
|
50
|
+
|
|
51
|
+
healthy: bool
|
|
52
|
+
latency_ms: int | None = None
|
|
53
|
+
error: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PlannerStatusResponse(BaseModel):
|
|
57
|
+
"""Response containing planner configuration status."""
|
|
58
|
+
|
|
59
|
+
model: str
|
|
60
|
+
llm_configured: bool
|
|
61
|
+
llm_provider: str | None
|
|
62
|
+
llm_health: LLMHealthCheck | None = None # Only populated when health_check=true
|
|
63
|
+
features: PlannerFeaturesStatus
|
|
64
|
+
max_followups_per_ticket: int
|
|
65
|
+
max_followups_per_tick: int
|
|
66
|
+
last_tick: LastTickStats | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _detect_llm_provider(model: str | None = None) -> tuple[bool, str | None]:
|
|
70
|
+
"""Detect if an LLM is configured and which provider.
|
|
71
|
+
|
|
72
|
+
Supports both API-key-based providers and CLI-based agents (e.g. claude, cursor).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Tuple of (is_configured, provider_name)
|
|
76
|
+
"""
|
|
77
|
+
import shutil
|
|
78
|
+
|
|
79
|
+
# CLI-based models (no API key needed — CLI handles auth)
|
|
80
|
+
# Model format: "cli/claude", "cli/cursor", etc.
|
|
81
|
+
if model and model.startswith("cli/"):
|
|
82
|
+
cli_name = model.split("/", 1)[1] # e.g. "claude", "cursor"
|
|
83
|
+
if shutil.which(cli_name):
|
|
84
|
+
return True, f"cli:{cli_name}"
|
|
85
|
+
# CLI binary not found on PATH
|
|
86
|
+
return False, f"cli:{cli_name} (not found)"
|
|
87
|
+
|
|
88
|
+
# Check for common API keys
|
|
89
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
90
|
+
return True, "openai"
|
|
91
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
92
|
+
return True, "anthropic"
|
|
93
|
+
if os.environ.get("AZURE_API_KEY"):
|
|
94
|
+
return True, "azure"
|
|
95
|
+
if os.environ.get("COHERE_API_KEY"):
|
|
96
|
+
return True, "cohere"
|
|
97
|
+
# Check for AWS Bedrock credentials
|
|
98
|
+
if os.environ.get("AWS_ACCESS_KEY_ID") and os.environ.get("AWS_SECRET_ACCESS_KEY"):
|
|
99
|
+
return True, "aws-bedrock"
|
|
100
|
+
# LiteLLM also supports LITELLM_API_KEY for some providers
|
|
101
|
+
if os.environ.get("LITELLM_API_KEY"):
|
|
102
|
+
return True, "litellm"
|
|
103
|
+
return False, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _check_llm_health(model: str) -> LLMHealthCheck:
|
|
107
|
+
"""Perform a minimal health check on the LLM.
|
|
108
|
+
|
|
109
|
+
Makes a tiny request to verify the LLM is accessible.
|
|
110
|
+
Uses max_tokens=1 to minimize cost.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
model: The model identifier to test.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
LLMHealthCheck with status, latency, and any error.
|
|
117
|
+
"""
|
|
118
|
+
import time
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
from litellm import acompletion
|
|
122
|
+
|
|
123
|
+
start_time = time.time()
|
|
124
|
+
|
|
125
|
+
# Minimal request to test connectivity
|
|
126
|
+
response = await acompletion(
|
|
127
|
+
model=model,
|
|
128
|
+
messages=[{"role": "user", "content": "Hi"}],
|
|
129
|
+
max_tokens=1,
|
|
130
|
+
timeout=10,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
134
|
+
|
|
135
|
+
# Check if we got a valid response
|
|
136
|
+
if response and response.choices:
|
|
137
|
+
return LLMHealthCheck(healthy=True, latency_ms=latency_ms)
|
|
138
|
+
else:
|
|
139
|
+
return LLMHealthCheck(
|
|
140
|
+
healthy=False,
|
|
141
|
+
latency_ms=latency_ms,
|
|
142
|
+
error="Empty response from LLM",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
error_msg = str(e)
|
|
147
|
+
# Truncate very long error messages
|
|
148
|
+
if len(error_msg) > 200:
|
|
149
|
+
error_msg = error_msg[:200] + "..."
|
|
150
|
+
logger.warning(f"LLM health check failed: {error_msg}")
|
|
151
|
+
return LLMHealthCheck(healthy=False, error=error_msg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _get_last_tick_stats(db: AsyncSession) -> LastTickStats | None:
|
|
155
|
+
"""Query last tick stats from recent planner events.
|
|
156
|
+
|
|
157
|
+
Looks at planner events from the last hour to find the most recent tick
|
|
158
|
+
and count actions by type.
|
|
159
|
+
|
|
160
|
+
OPTIMIZATION NOTE: This queries all planner events in the last hour
|
|
161
|
+
and computes counts in Python. For high-volume usage, consider:
|
|
162
|
+
- Store a single planner_tick_summary event per tick with counts in payload_json
|
|
163
|
+
- Then this function reads one row instead of scanning many
|
|
164
|
+
Not needed for MVP, but keep in mind for scale.
|
|
165
|
+
"""
|
|
166
|
+
# Find planner events from the last hour
|
|
167
|
+
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
|
168
|
+
|
|
169
|
+
result = await db.execute(
|
|
170
|
+
select(TicketEvent)
|
|
171
|
+
.options(selectinload(TicketEvent.ticket))
|
|
172
|
+
.where(
|
|
173
|
+
and_(
|
|
174
|
+
TicketEvent.actor_type == ActorType.PLANNER.value,
|
|
175
|
+
TicketEvent.actor_id == "planner",
|
|
176
|
+
TicketEvent.created_at >= one_hour_ago,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
.order_by(TicketEvent.created_at.desc())
|
|
180
|
+
)
|
|
181
|
+
events = list(result.scalars().all())
|
|
182
|
+
|
|
183
|
+
if not events:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Find the most recent tick boundary (events within 5 seconds of the newest)
|
|
187
|
+
newest_time = events[0].created_at
|
|
188
|
+
tick_window = timedelta(seconds=5)
|
|
189
|
+
|
|
190
|
+
tick_events = [e for e in events if (newest_time - e.created_at) < tick_window]
|
|
191
|
+
|
|
192
|
+
# Count by type based on payload markers
|
|
193
|
+
executed = 0
|
|
194
|
+
followups_created = 0
|
|
195
|
+
reflections_added = 0
|
|
196
|
+
|
|
197
|
+
for event in tick_events:
|
|
198
|
+
payload = event.get_payload() or {}
|
|
199
|
+
|
|
200
|
+
if payload.get("action") == "enqueued_execute":
|
|
201
|
+
executed += 1
|
|
202
|
+
elif payload.get("planner_followup_created"):
|
|
203
|
+
followups_created += 1
|
|
204
|
+
elif payload.get("planner_reflection"):
|
|
205
|
+
reflections_added += 1
|
|
206
|
+
|
|
207
|
+
return LastTickStats(
|
|
208
|
+
executed=executed,
|
|
209
|
+
followups_created=followups_created,
|
|
210
|
+
reflections_added=reflections_added,
|
|
211
|
+
last_tick_at=newest_time,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@router.get(
|
|
216
|
+
"/status",
|
|
217
|
+
response_model=PlannerStatusResponse,
|
|
218
|
+
summary="Get planner configuration status",
|
|
219
|
+
)
|
|
220
|
+
async def get_planner_status(
|
|
221
|
+
db: AsyncSession = Depends(get_db),
|
|
222
|
+
health_check: bool = Query(
|
|
223
|
+
default=False,
|
|
224
|
+
description="If true, performs a live health check on the LLM (makes a minimal API call)",
|
|
225
|
+
),
|
|
226
|
+
) -> PlannerStatusResponse:
|
|
227
|
+
"""
|
|
228
|
+
Get the current planner configuration status.
|
|
229
|
+
|
|
230
|
+
Returns information about:
|
|
231
|
+
- Which LLM model is configured
|
|
232
|
+
- Whether an LLM API key is present
|
|
233
|
+
- Which features are enabled
|
|
234
|
+
- Safety caps for follow-ups
|
|
235
|
+
- Stats from the last tick (executed, follow-ups, reflections)
|
|
236
|
+
|
|
237
|
+
**Optional Health Check:**
|
|
238
|
+
Pass `?health_check=true` to verify the LLM is actually accessible.
|
|
239
|
+
This makes a minimal API call (max_tokens=1) to test connectivity.
|
|
240
|
+
Note: This incurs a small cost and adds latency.
|
|
241
|
+
|
|
242
|
+
This helps debug "why didn't follow-ups happen?" issues.
|
|
243
|
+
"""
|
|
244
|
+
config_service = ConfigService()
|
|
245
|
+
# Load fresh config without cache (in case draft.yaml was edited)
|
|
246
|
+
config = config_service.load_config(use_cache=False).planner_config
|
|
247
|
+
|
|
248
|
+
llm_configured, llm_provider = _detect_llm_provider(model=config.model)
|
|
249
|
+
|
|
250
|
+
# Get last tick stats
|
|
251
|
+
last_tick = await _get_last_tick_stats(db)
|
|
252
|
+
|
|
253
|
+
# Optionally perform LLM health check
|
|
254
|
+
llm_health = None
|
|
255
|
+
if health_check and llm_configured:
|
|
256
|
+
llm_health = await _check_llm_health(config.model)
|
|
257
|
+
|
|
258
|
+
return PlannerStatusResponse(
|
|
259
|
+
model=config.model,
|
|
260
|
+
llm_configured=llm_configured,
|
|
261
|
+
llm_provider=llm_provider,
|
|
262
|
+
llm_health=llm_health,
|
|
263
|
+
features=PlannerFeaturesStatus(
|
|
264
|
+
auto_execute=config.features.auto_execute,
|
|
265
|
+
propose_followups=config.features.propose_followups,
|
|
266
|
+
generate_reflections=config.features.generate_reflections,
|
|
267
|
+
),
|
|
268
|
+
max_followups_per_ticket=config.max_followups_per_ticket,
|
|
269
|
+
max_followups_per_tick=config.max_followups_per_tick,
|
|
270
|
+
last_tick=last_tick,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@router.post(
|
|
275
|
+
"/tick",
|
|
276
|
+
response_model=PlannerTickResponse,
|
|
277
|
+
summary="Run one planner decision cycle (debug/manual)",
|
|
278
|
+
)
|
|
279
|
+
async def planner_tick(
|
|
280
|
+
request: PlannerTickRequest = PlannerTickRequest(),
|
|
281
|
+
db: AsyncSession = Depends(get_db),
|
|
282
|
+
) -> PlannerTickResponse:
|
|
283
|
+
"""
|
|
284
|
+
Run one decision cycle of the planner (single tick for debugging).
|
|
285
|
+
|
|
286
|
+
**For normal operation, use `/planner/start` instead.**
|
|
287
|
+
|
|
288
|
+
This endpoint runs a single decision cycle and returns immediately.
|
|
289
|
+
Use it for debugging or manual control.
|
|
290
|
+
|
|
291
|
+
The planner evaluates the current board state and takes actions:
|
|
292
|
+
|
|
293
|
+
1. **Queue tickets** (deterministic): If no ticket is executing OR verifying,
|
|
294
|
+
queues ALL planned tickets ordered by priority.
|
|
295
|
+
|
|
296
|
+
2. **Handle blocked tickets** (LLM-powered): For BLOCKED tickets without
|
|
297
|
+
follow-ups, generates and creates follow-up ticket proposals.
|
|
298
|
+
|
|
299
|
+
3. **Generate reflections** (LLM-powered): For DONE tickets without
|
|
300
|
+
reflections, generates summary comments as TicketEvents.
|
|
301
|
+
|
|
302
|
+
**Concurrency Safety:**
|
|
303
|
+
- Only one tick can run at a time (uses database lock)
|
|
304
|
+
- Returns 409 Conflict if another tick is already in progress
|
|
305
|
+
|
|
306
|
+
Returns a summary of actions taken during this tick.
|
|
307
|
+
"""
|
|
308
|
+
service = PlannerService(db)
|
|
309
|
+
try:
|
|
310
|
+
return await service.tick()
|
|
311
|
+
except PlannerLockError as e:
|
|
312
|
+
raise HTTPException(
|
|
313
|
+
status_code=409,
|
|
314
|
+
detail=str(e),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@router.post(
|
|
319
|
+
"/start",
|
|
320
|
+
response_model=PlannerStartResponse,
|
|
321
|
+
summary="Start autopilot - run until queue is empty",
|
|
322
|
+
)
|
|
323
|
+
async def planner_start(
|
|
324
|
+
request: PlannerStartRequest = PlannerStartRequest(),
|
|
325
|
+
db: AsyncSession = Depends(get_db),
|
|
326
|
+
) -> PlannerStartResponse:
|
|
327
|
+
"""
|
|
328
|
+
Start the autopilot and run until all planned tickets are processed.
|
|
329
|
+
|
|
330
|
+
This is the main entry point for automated ticket processing:
|
|
331
|
+
|
|
332
|
+
1. **Queues all planned tickets** ordered by priority
|
|
333
|
+
2. **Polls for completion** - waits for each ticket to finish
|
|
334
|
+
3. **Continues until queue is empty** or max duration reached
|
|
335
|
+
4. **Returns summary** of all actions taken
|
|
336
|
+
|
|
337
|
+
**Flow:**
|
|
338
|
+
- Queues all PLANNED tickets as execute jobs
|
|
339
|
+
- Tickets transition: PLANNED → EXECUTING → VERIFYING → DONE/BLOCKED
|
|
340
|
+
- Polls every `poll_interval_seconds` to check status
|
|
341
|
+
- Stops when no more PLANNED/EXECUTING/VERIFYING tickets exist
|
|
342
|
+
|
|
343
|
+
**Timeouts:**
|
|
344
|
+
- Default max duration: 1 hour
|
|
345
|
+
- Each individual job has its own timeout (from config)
|
|
346
|
+
|
|
347
|
+
**Use `/tickets/{id}/execute` to run a single specific ticket.**
|
|
348
|
+
"""
|
|
349
|
+
import asyncio
|
|
350
|
+
import time
|
|
351
|
+
|
|
352
|
+
from app.database import async_session_maker
|
|
353
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
354
|
+
from app.models.ticket import Ticket
|
|
355
|
+
from app.state_machine import TicketState
|
|
356
|
+
|
|
357
|
+
start_time = time.time()
|
|
358
|
+
all_actions: list[PlannerAction] = []
|
|
359
|
+
tickets_completed = 0
|
|
360
|
+
tickets_failed = 0
|
|
361
|
+
|
|
362
|
+
# Initial tick to queue all planned tickets
|
|
363
|
+
# force_execute=True ensures tickets are queued even if auto_execute is disabled in config
|
|
364
|
+
# This allows users to keep auto_execute=false but still manually trigger autopilot
|
|
365
|
+
service = PlannerService(db)
|
|
366
|
+
try:
|
|
367
|
+
initial_result = await service.tick(force_execute=True)
|
|
368
|
+
all_actions.extend(initial_result.actions)
|
|
369
|
+
except PlannerLockError as e:
|
|
370
|
+
raise HTTPException(
|
|
371
|
+
status_code=409,
|
|
372
|
+
detail=str(e),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Count initially queued tickets
|
|
376
|
+
tickets_queued = sum(
|
|
377
|
+
1 for a in initial_result.actions if a.action_type == "enqueued_execute"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if tickets_queued == 0:
|
|
381
|
+
return PlannerStartResponse(
|
|
382
|
+
status="completed",
|
|
383
|
+
message="No planned tickets to process",
|
|
384
|
+
tickets_queued=0,
|
|
385
|
+
tickets_completed=0,
|
|
386
|
+
tickets_failed=0,
|
|
387
|
+
total_actions=all_actions,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Poll loop - wait for all tickets to complete
|
|
391
|
+
# IMPORTANT: We release the DB connection between polls to avoid holding it for hours
|
|
392
|
+
while True:
|
|
393
|
+
elapsed = time.time() - start_time
|
|
394
|
+
if elapsed >= request.max_duration_seconds:
|
|
395
|
+
return PlannerStartResponse(
|
|
396
|
+
status="timeout",
|
|
397
|
+
message=f"Max duration of {request.max_duration_seconds}s reached",
|
|
398
|
+
tickets_queued=tickets_queued,
|
|
399
|
+
tickets_completed=tickets_completed,
|
|
400
|
+
tickets_failed=tickets_failed,
|
|
401
|
+
total_actions=all_actions,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Use a fresh session for each poll to avoid holding connections
|
|
405
|
+
async with async_session_maker() as poll_db:
|
|
406
|
+
# Check current state
|
|
407
|
+
# Count active tickets (executing or verifying)
|
|
408
|
+
active_result = await poll_db.execute(
|
|
409
|
+
select(func.count(Ticket.id)).where(
|
|
410
|
+
Ticket.state.in_(
|
|
411
|
+
[
|
|
412
|
+
TicketState.EXECUTING.value,
|
|
413
|
+
TicketState.VERIFYING.value,
|
|
414
|
+
]
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
active_count = active_result.scalar() or 0
|
|
419
|
+
|
|
420
|
+
# Count planned tickets (still waiting)
|
|
421
|
+
planned_result = await poll_db.execute(
|
|
422
|
+
select(func.count(Ticket.id)).where(
|
|
423
|
+
Ticket.state == TicketState.PLANNED.value
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
planned_count = planned_result.scalar() or 0
|
|
427
|
+
|
|
428
|
+
# Count queued/running jobs
|
|
429
|
+
jobs_result = await poll_db.execute(
|
|
430
|
+
select(func.count(Job.id)).where(
|
|
431
|
+
and_(
|
|
432
|
+
Job.kind == JobKind.EXECUTE.value,
|
|
433
|
+
Job.status.in_(
|
|
434
|
+
[JobStatus.QUEUED.value, JobStatus.RUNNING.value]
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
jobs_pending = jobs_result.scalar() or 0
|
|
440
|
+
|
|
441
|
+
# Count completed and failed since start
|
|
442
|
+
done_result = await poll_db.execute(
|
|
443
|
+
select(func.count(Ticket.id)).where(
|
|
444
|
+
Ticket.state == TicketState.DONE.value
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
tickets_completed = done_result.scalar() or 0
|
|
448
|
+
|
|
449
|
+
blocked_result = await poll_db.execute(
|
|
450
|
+
select(func.count(Ticket.id)).where(
|
|
451
|
+
Ticket.state == TicketState.BLOCKED.value
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
tickets_failed = blocked_result.scalar() or 0
|
|
455
|
+
|
|
456
|
+
logger.debug(
|
|
457
|
+
f"Autopilot poll: active={active_count}, planned={planned_count}, "
|
|
458
|
+
f"jobs_pending={jobs_pending}, done={tickets_completed}, blocked={tickets_failed}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# If nothing is active and nothing planned, we're done
|
|
462
|
+
if active_count == 0 and planned_count == 0 and jobs_pending == 0:
|
|
463
|
+
# Run one more tick to handle any reflections/followups
|
|
464
|
+
async with async_session_maker() as final_db:
|
|
465
|
+
try:
|
|
466
|
+
final_service = PlannerService(final_db)
|
|
467
|
+
final_result = await final_service.tick()
|
|
468
|
+
all_actions.extend(final_result.actions)
|
|
469
|
+
except PlannerLockError:
|
|
470
|
+
pass # Ignore lock errors on final tick
|
|
471
|
+
|
|
472
|
+
return PlannerStartResponse(
|
|
473
|
+
status="completed",
|
|
474
|
+
message=f"All {tickets_queued} ticket(s) processed",
|
|
475
|
+
tickets_queued=tickets_queued,
|
|
476
|
+
tickets_completed=tickets_completed,
|
|
477
|
+
tickets_failed=tickets_failed,
|
|
478
|
+
total_actions=all_actions,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# If there are still planned tickets but nothing active, run another tick
|
|
482
|
+
if active_count == 0 and jobs_pending == 0 and planned_count > 0:
|
|
483
|
+
async with async_session_maker() as tick_db:
|
|
484
|
+
try:
|
|
485
|
+
tick_service = PlannerService(tick_db)
|
|
486
|
+
tick_result = await tick_service.tick()
|
|
487
|
+
all_actions.extend(tick_result.actions)
|
|
488
|
+
except PlannerLockError:
|
|
489
|
+
pass # Ignore, another tick is running
|
|
490
|
+
|
|
491
|
+
# Wait before next poll
|
|
492
|
+
await asyncio.sleep(request.poll_interval_seconds)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class ReleaseLockResponse(BaseModel):
|
|
496
|
+
"""Response from planner lock release."""
|
|
497
|
+
|
|
498
|
+
released: bool
|
|
499
|
+
message: str
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@router.post(
|
|
503
|
+
"/release-lock",
|
|
504
|
+
response_model=ReleaseLockResponse,
|
|
505
|
+
summary="Force-release the planner lock (emergency admin action)",
|
|
506
|
+
)
|
|
507
|
+
async def release_planner_lock(
|
|
508
|
+
db: AsyncSession = Depends(get_db),
|
|
509
|
+
) -> ReleaseLockResponse:
|
|
510
|
+
"""
|
|
511
|
+
Force-release the planner lock.
|
|
512
|
+
|
|
513
|
+
**WARNING:** This is an emergency admin action for when the planner gets stuck.
|
|
514
|
+
Only use this if the planner tick is hung and no tick is actually running.
|
|
515
|
+
|
|
516
|
+
Deletes the planner_tick lock row from the planner_locks table.
|
|
517
|
+
"""
|
|
518
|
+
from sqlalchemy import delete as sql_delete
|
|
519
|
+
|
|
520
|
+
from app.models.planner_lock import PlannerLock
|
|
521
|
+
|
|
522
|
+
result = await db.execute(
|
|
523
|
+
sql_delete(PlannerLock).where(PlannerLock.lock_key == "planner_tick")
|
|
524
|
+
)
|
|
525
|
+
await db.commit()
|
|
526
|
+
|
|
527
|
+
if result.rowcount > 0:
|
|
528
|
+
logger.warning("Planner lock force-released by admin action")
|
|
529
|
+
return ReleaseLockResponse(
|
|
530
|
+
released=True,
|
|
531
|
+
message="Planner lock released successfully",
|
|
532
|
+
)
|
|
533
|
+
else:
|
|
534
|
+
return ReleaseLockResponse(
|
|
535
|
+
released=False,
|
|
536
|
+
message="No planner lock was held",
|
|
537
|
+
)
|