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.
Files changed (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. 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
+ )