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,1007 @@
1
+ """UDAR Planner Service - Understand-Decide-Act-Validate-Review architecture.
2
+
3
+ This service implements a lean, cost-optimized agent for ticket generation
4
+ and incremental replanning using LangGraph.
5
+
6
+ Key Cost Optimizations:
7
+ - Understand phase: Deterministic (0 LLM calls)
8
+ - Decide phase: Single batched LLM call (1 LLM call)
9
+ - Act phase: Deterministic (0 LLM calls)
10
+ - Validate phase: Mostly deterministic (0-1 LLM calls, optional)
11
+ - Review phase: Deterministic (0 LLM calls)
12
+
13
+ Total: 1-2 LLM calls per goal for initial generation
14
+ """
15
+
16
+ import json
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import TypedDict
20
+
21
+ from langgraph.graph import END, StateGraph
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+
24
+ from app.models.goal import Goal
25
+ from app.models.ticket import Ticket
26
+ from app.services.agent_memory_service import AgentMemoryService
27
+ from app.services.context_gatherer import ContextGatherer
28
+ from app.services.langchain_adapter import LangChainLLMAdapter
29
+ from app.services.llm_service import LLMService
30
+
31
+
32
+ class UDARState(TypedDict):
33
+ """State for UDAR agent workflow.
34
+
35
+ This state is passed through all phases of the UDAR cycle.
36
+ """
37
+
38
+ # Inputs
39
+ goal_id: str
40
+ goal_title: str
41
+ goal_description: str
42
+ repo_root: str
43
+ trigger: str # "initial_generation" | "post_completion" | "manual"
44
+
45
+ # Context (Understand phase - deterministic)
46
+ codebase_summary: str | None
47
+ existing_tickets: list[dict]
48
+ existing_ticket_count: int
49
+ project_type: str | None
50
+
51
+ # Decisions (Decide phase - 1 LLM call)
52
+ proposed_tickets: list[dict]
53
+ reasoning: str
54
+ should_generate_new: bool
55
+ llm_calls_made: int # Track LLM usage
56
+
57
+ # Validation (Validate phase - deterministic or 0-1 LLM call)
58
+ validated_tickets: list[dict]
59
+ validation_results: list[dict]
60
+
61
+ # Review (Review phase - deterministic)
62
+ final_tickets: list[dict]
63
+ review_summary: str
64
+
65
+ # Metadata
66
+ phase: str
67
+ iteration: int
68
+ errors: list[str]
69
+ total_input_tokens: int
70
+ total_output_tokens: int
71
+
72
+
73
+ class UDARPlannerService:
74
+ """UDAR agent service for lean, adaptive ticket generation.
75
+
76
+ This service orchestrates the UDAR (Understand-Decide-Act-Validate-Review)
77
+ workflow using LangGraph, with a focus on minimizing LLM calls.
78
+
79
+ Example:
80
+ service = UDARPlannerService(db)
81
+ result = await service.generate_from_goal(goal_id="goal-123")
82
+ """
83
+
84
+ def __init__(self, db: AsyncSession):
85
+ """Initialize UDAR planner service.
86
+
87
+ Args:
88
+ db: Async database session
89
+ """
90
+ self.db = db
91
+ self.llm_service = LLMService()
92
+ self.llm_adapter = LangChainLLMAdapter(llm_service=self.llm_service)
93
+ self.memory_service = AgentMemoryService(db)
94
+
95
+ # Build LangGraph workflow
96
+ self.agent = self._build_workflow()
97
+
98
+ def _build_workflow(self) -> StateGraph:
99
+ """Build the LangGraph state machine for UDAR.
100
+
101
+ Returns:
102
+ Compiled LangGraph agent
103
+ """
104
+ workflow = StateGraph(UDARState)
105
+
106
+ # Add nodes for each phase
107
+ workflow.add_node("understand", self._understand_node)
108
+ workflow.add_node("decide", self._decide_node)
109
+ workflow.add_node("act", self._act_node)
110
+ workflow.add_node("validate", self._validate_node)
111
+ workflow.add_node("review", self._review_node)
112
+
113
+ # Define edges
114
+ workflow.set_entry_point("understand")
115
+ workflow.add_edge("understand", "decide")
116
+ workflow.add_edge("decide", "act")
117
+ workflow.add_edge("act", "validate")
118
+ workflow.add_conditional_edges(
119
+ "validate",
120
+ self._should_retry,
121
+ {
122
+ "retry": "decide", # Self-correction loop (max 1 iteration)
123
+ "proceed": "review",
124
+ },
125
+ )
126
+ workflow.add_edge("review", END)
127
+
128
+ return workflow.compile()
129
+
130
+ async def _understand_node(self, state: UDARState) -> UDARState:
131
+ """Understand phase: Gather context deterministically (0 LLM calls).
132
+
133
+ This phase collects:
134
+ - Codebase structure via ContextGatherer
135
+ - Existing tickets from database
136
+ - Goal details
137
+
138
+ Args:
139
+ state: Current UDAR state
140
+
141
+ Returns:
142
+ Updated state with context
143
+ """
144
+ try:
145
+ # Mark phase
146
+ state["phase"] = "understand"
147
+
148
+ # Gather codebase context (deterministic, cached)
149
+ gatherer = ContextGatherer(repo_path=Path(state["repo_root"]))
150
+ context = gatherer.gather(
151
+ include_readme=True,
152
+ include_todos=True,
153
+ max_files=1000,
154
+ )
155
+
156
+ # Query existing tickets (deterministic)
157
+ from sqlalchemy import select
158
+
159
+ stmt = select(Ticket).where(Ticket.goal_id == state["goal_id"])
160
+ result = await self.db.execute(stmt)
161
+ tickets = result.scalars().all()
162
+
163
+ # Build context summary (deterministic)
164
+ state["codebase_summary"] = context.to_prompt_string()
165
+ state["project_type"] = context.project_type
166
+ state["existing_tickets"] = [
167
+ {
168
+ "id": t.id,
169
+ "title": t.title,
170
+ "state": t.state,
171
+ "priority": t.priority,
172
+ }
173
+ for t in tickets
174
+ ]
175
+ state["existing_ticket_count"] = len(tickets)
176
+
177
+ # Log understanding phase
178
+ await self._log_phase(
179
+ state,
180
+ "understanding",
181
+ {
182
+ "project_type": context.project_type,
183
+ "file_count": len(context.file_tree),
184
+ "existing_ticket_count": len(tickets),
185
+ },
186
+ )
187
+
188
+ except Exception as e:
189
+ state["errors"].append(f"Understand phase error: {str(e)}")
190
+
191
+ return state
192
+
193
+ async def _decide_node(self, state: UDARState) -> UDARState:
194
+ """Decide phase: Call LLM to generate ticket proposals (1 LLM call).
195
+
196
+ This is the PRIMARY LLM call in the UDAR workflow. It generates
197
+ ALL tickets in a single batched call.
198
+
199
+ If this is a retry iteration (iteration > 0), incorporates validation
200
+ feedback from the previous attempt to self-correct.
201
+
202
+ Args:
203
+ state: Current UDAR state
204
+
205
+ Returns:
206
+ Updated state with proposed tickets
207
+ """
208
+ try:
209
+ state["phase"] = "decide"
210
+
211
+ # Build prompt with context (includes validation feedback if retry)
212
+ prompt = self._build_decide_prompt(state)
213
+
214
+ # SINGLE LLM CALL for all tickets
215
+ response = await self.llm_adapter._acall(prompt)
216
+ state["llm_calls_made"] = state.get("llm_calls_made", 0) + 1
217
+
218
+ # Parse LLM response
219
+ parsed = self._parse_llm_response(response)
220
+ state["proposed_tickets"] = parsed["tickets"]
221
+ state["reasoning"] = parsed["reasoning"]
222
+ state["should_generate_new"] = len(parsed["tickets"]) > 0
223
+
224
+ # Log decision phase
225
+ await self._log_phase(
226
+ state,
227
+ "decision",
228
+ {
229
+ "tickets_proposed": len(state["proposed_tickets"]),
230
+ "reasoning_length": len(state["reasoning"]),
231
+ "llm_calls": 1,
232
+ "is_retry": state["iteration"] > 0,
233
+ },
234
+ )
235
+
236
+ except Exception as e:
237
+ state["errors"].append(f"Decide phase error: {str(e)}")
238
+ state["proposed_tickets"] = []
239
+
240
+ return state
241
+
242
+ async def _act_node(self, state: UDARState) -> UDARState:
243
+ """Act phase: Format tickets deterministically (0 LLM calls).
244
+
245
+ This phase converts LLM proposals into database-ready schemas
246
+ using deterministic logic.
247
+
248
+ Args:
249
+ state: Current UDAR state
250
+
251
+ Returns:
252
+ Updated state with formatted tickets
253
+ """
254
+ try:
255
+ state["phase"] = "act"
256
+
257
+ # Format each ticket (deterministic)
258
+ formatted_tickets = []
259
+ for ticket_proposal in state["proposed_tickets"]:
260
+ formatted = self._format_ticket_proposal(ticket_proposal, state)
261
+ formatted_tickets.append(formatted)
262
+
263
+ state["proposed_tickets"] = formatted_tickets
264
+
265
+ # Log act phase
266
+ await self._log_phase(
267
+ state,
268
+ "act",
269
+ {
270
+ "tickets_formatted": len(formatted_tickets),
271
+ },
272
+ )
273
+
274
+ except Exception as e:
275
+ state["errors"].append(f"Act phase error: {str(e)}")
276
+
277
+ return state
278
+
279
+ async def _validate_node(self, state: UDARState) -> UDARState:
280
+ """Validate phase: Check proposals deterministically (0 LLM calls).
281
+
282
+ This phase uses deterministic validation:
283
+ - Duplicate detection (exact title match)
284
+ - Dependency validation (blocker exists)
285
+ - Schema validation (required fields)
286
+
287
+ Optional LLM validation is disabled by default to save quota.
288
+
289
+ If validation fails and this is a retry iteration, the feedback
290
+ from previous validation is available in state["validation_feedback"].
291
+
292
+ Args:
293
+ state: Current UDAR state
294
+
295
+ Returns:
296
+ Updated state with validation results
297
+ """
298
+ try:
299
+ state["phase"] = "validate"
300
+
301
+ validated_tickets = []
302
+ validation_results = []
303
+ validation_feedback_messages = []
304
+
305
+ for ticket in state["proposed_tickets"]:
306
+ # Deterministic validation
307
+ is_valid, reason = await self._validate_ticket_deterministic(
308
+ ticket, state
309
+ )
310
+
311
+ validation_results.append(
312
+ {
313
+ "ticket_title": ticket["title"],
314
+ "is_valid": is_valid,
315
+ "reason": reason,
316
+ "llm_used": False,
317
+ }
318
+ )
319
+
320
+ if is_valid:
321
+ validated_tickets.append(ticket)
322
+ else:
323
+ # Collect feedback for self-correction
324
+ validation_feedback_messages.append(
325
+ f"- '{ticket['title']}': {reason}"
326
+ )
327
+
328
+ state["validated_tickets"] = validated_tickets
329
+ state["validation_results"] = validation_results
330
+
331
+ # Build feedback for self-correction (if needed)
332
+ if validation_feedback_messages:
333
+ state["validation_feedback"] = (
334
+ "The following tickets failed validation:\n"
335
+ + "\n".join(validation_feedback_messages)
336
+ )
337
+ else:
338
+ state["validation_feedback"] = ""
339
+
340
+ # Log validation phase
341
+ await self._log_phase(
342
+ state,
343
+ "validation",
344
+ {
345
+ "tickets_validated": len(validated_tickets),
346
+ "tickets_rejected": len(state["proposed_tickets"])
347
+ - len(validated_tickets),
348
+ "has_failures": len(validation_feedback_messages) > 0,
349
+ },
350
+ )
351
+
352
+ except Exception as e:
353
+ state["errors"].append(f"Validate phase error: {str(e)}")
354
+
355
+ return state
356
+
357
+ async def _review_node(self, state: UDARState) -> UDARState:
358
+ """Review phase: Create database records (0 LLM calls).
359
+
360
+ This phase commits validated tickets to the database and
361
+ stores reasoning in agent memory as a checkpoint.
362
+
363
+ Args:
364
+ state: Current UDAR state
365
+
366
+ Returns:
367
+ Updated state with final tickets
368
+ """
369
+ try:
370
+ state["phase"] = "review"
371
+
372
+ final_tickets = []
373
+
374
+ # Create ticket records
375
+ for ticket_data in state["validated_tickets"]:
376
+ ticket = Ticket(
377
+ goal_id=state["goal_id"],
378
+ title=ticket_data["title"],
379
+ description=ticket_data["description"],
380
+ state="proposed", # All start as PROPOSED
381
+ priority=ticket_data["priority"],
382
+ board_id=ticket_data.get("board_id"),
383
+ )
384
+ self.db.add(ticket)
385
+ final_tickets.append(ticket_data)
386
+
387
+ await self.db.commit()
388
+
389
+ state["final_tickets"] = final_tickets
390
+ state["review_summary"] = f"Created {len(final_tickets)} tickets"
391
+
392
+ # Save checkpoint to agent memory (compressed)
393
+ checkpoint_id = (
394
+ f"{state['goal_id']}-{state['trigger']}-{datetime.utcnow().isoformat()}"
395
+ )
396
+ await self.memory_service.save_checkpoint(
397
+ goal_id=state["goal_id"],
398
+ checkpoint_id=checkpoint_id,
399
+ state=state,
400
+ )
401
+
402
+ # Log review phase
403
+ await self._log_phase(
404
+ state,
405
+ "review",
406
+ {
407
+ "tickets_created": len(final_tickets),
408
+ "total_llm_calls": state.get("llm_calls_made", 0),
409
+ "checkpoint_saved": True,
410
+ },
411
+ )
412
+
413
+ except Exception as e:
414
+ state["errors"].append(f"Review phase error: {str(e)}")
415
+ await self.db.rollback()
416
+
417
+ return state
418
+
419
+ def _should_retry(self, state: UDARState) -> str:
420
+ """Conditional edge: Decide whether to retry validation.
421
+
422
+ Retries if validation failed AND iteration < max_self_correction_iterations.
423
+ Max iterations is configured in draft.yaml (default: 1).
424
+
425
+ Args:
426
+ state: Current UDAR state
427
+
428
+ Returns:
429
+ "retry" or "proceed"
430
+ """
431
+ from app.services.config_service import ConfigService
432
+
433
+ failed_count = sum(1 for r in state["validation_results"] if not r["is_valid"])
434
+
435
+ # Get max iterations from config
436
+ config = ConfigService().load_config()
437
+ max_iterations = config.planner_config.udar.max_self_correction_iterations
438
+
439
+ # Retry if validation failed and under iteration limit
440
+ if failed_count > 0 and state["iteration"] < max_iterations:
441
+ state["iteration"] += 1
442
+ return "retry"
443
+
444
+ return "proceed"
445
+
446
+ # Helper methods
447
+
448
+ def _build_decide_prompt(self, state: UDARState) -> str:
449
+ """Build prompt for Decide phase LLM call.
450
+
451
+ If this is a retry iteration, incorporates validation feedback
452
+ to help the LLM self-correct.
453
+
454
+ Args:
455
+ state: Current UDAR state
456
+
457
+ Returns:
458
+ Prompt string for LLM
459
+ """
460
+ # Check if this is a retry with validation feedback
461
+ validation_feedback = state.get("validation_feedback", "")
462
+ is_retry = state["iteration"] > 0
463
+
464
+ prompt = f"""You are a software project planner. Generate tickets for the following goal:
465
+
466
+ **Goal:** {state["goal_title"]}
467
+ **Description:** {state["goal_description"]}
468
+
469
+ **Codebase Context:**
470
+ {state["codebase_summary"][:2000]} # Cap context to save tokens
471
+
472
+ **Existing Tickets:**
473
+ {json.dumps(state["existing_tickets"][:10], indent=2)} # Cap at 10
474
+ """
475
+
476
+ # Add validation feedback if this is a retry
477
+ if is_retry and validation_feedback:
478
+ prompt += f"""
479
+
480
+ **IMPORTANT: Previous Attempt Failed Validation**
481
+
482
+ Your previous ticket proposals had these issues:
483
+ {validation_feedback}
484
+
485
+ Please revise the ticket proposals to address these validation failures:
486
+ - Avoid duplicate titles (check existing tickets)
487
+ - Ensure all required fields are present
488
+ - Use clear, specific titles (at least 5 characters)
489
+ """
490
+
491
+ prompt += """
492
+ Generate a list of tickets needed to achieve this goal. Each ticket should:
493
+ 1. Have a clear, actionable title (minimum 5 characters)
494
+ 2. Include a description with acceptance criteria
495
+ 3. Specify priority (0-100, higher = more important)
496
+ 4. Optionally specify "blocked_by" (title of blocking ticket)
497
+
498
+ Return JSON in this format:
499
+ {{
500
+ "reasoning": "Brief explanation of why these tickets are needed",
501
+ "tickets": [
502
+ {{
503
+ "title": "Implement authentication models",
504
+ "description": "Create User, Session models with SQLAlchemy...",
505
+ "priority": 90,
506
+ "blocked_by": null
507
+ }},
508
+ ...
509
+ ]
510
+ }}
511
+ """
512
+ return prompt
513
+
514
+ def _parse_llm_response(self, response: str) -> dict:
515
+ """Parse LLM JSON response."""
516
+ try:
517
+ # Try to extract JSON from response
518
+ if "```json" in response:
519
+ json_str = response.split("```json")[1].split("```")[0].strip()
520
+ elif "```" in response:
521
+ json_str = response.split("```")[1].split("```")[0].strip()
522
+ else:
523
+ json_str = response.strip()
524
+
525
+ parsed = json.loads(json_str)
526
+ return parsed
527
+
528
+ except Exception:
529
+ # Fallback if parsing fails
530
+ return {
531
+ "reasoning": "Failed to parse LLM response",
532
+ "tickets": [],
533
+ }
534
+
535
+ def _format_ticket_proposal(self, proposal: dict, state: UDARState) -> dict:
536
+ """Format ticket proposal with defaults."""
537
+ return {
538
+ "title": proposal.get("title", "Untitled"),
539
+ "description": proposal.get("description", ""),
540
+ "priority": proposal.get("priority", 50),
541
+ "blocked_by": proposal.get("blocked_by"),
542
+ "board_id": state.get("board_id"),
543
+ }
544
+
545
+ async def _validate_ticket_deterministic(
546
+ self,
547
+ ticket: dict,
548
+ state: UDARState,
549
+ ) -> tuple[bool, str]:
550
+ """Validate ticket using deterministic checks (no LLM)."""
551
+ # Check for duplicates (exact title match)
552
+ for existing in state["existing_tickets"]:
553
+ if existing["title"].lower() == ticket["title"].lower():
554
+ return False, f"Duplicate of existing ticket: {existing['id']}"
555
+
556
+ # Check required fields
557
+ if not ticket.get("title"):
558
+ return False, "Missing title"
559
+
560
+ if len(ticket["title"]) < 5:
561
+ return False, "Title too short"
562
+
563
+ # All checks passed
564
+ return True, "Valid"
565
+
566
+ async def _log_phase(self, state: UDARState, phase: str, metadata: dict):
567
+ """Log UDAR phase as TicketEvent.
568
+
569
+ For ticket generation (no job_id), we log agent activity as events
570
+ attached to the goal. This provides an audit trail of agent reasoning.
571
+
572
+ Args:
573
+ state: Current UDAR state
574
+ phase: Phase name (understanding, decision, validation, etc.)
575
+ metadata: Phase-specific data to log
576
+ """
577
+ from app.models.ticket_event import TicketEvent
578
+
579
+ # Create event describing agent activity
580
+ event = TicketEvent(
581
+ goal_id=state["goal_id"],
582
+ ticket_id=None, # Not ticket-specific yet
583
+ event_type="comment", # Use comment type for agent logs
584
+ actor_type="agent",
585
+ payload={
586
+ "agent_phase": phase,
587
+ "metadata": metadata,
588
+ "trigger": state.get("trigger", "unknown"),
589
+ },
590
+ )
591
+ self.db.add(event)
592
+ # Note: Commit happens at end of workflow, not per-phase
593
+
594
+ # Incremental Replanning (Phase 3)
595
+
596
+ async def replan_after_completion(self, ticket_ids: list[str]) -> dict:
597
+ """Analyze completed tickets IN BATCH and generate follow-ups if needed.
598
+
599
+ COST OPTIMIZATION: Batches multiple tickets into single LLM call.
600
+ Only calls LLM if changes are significant (>10 files OR verification failed).
601
+
602
+ Args:
603
+ ticket_ids: List of ticket IDs to analyze (batch)
604
+
605
+ Returns:
606
+ Dict with replanning results:
607
+ {
608
+ "tickets_analyzed": 5,
609
+ "significant_tickets": 2,
610
+ "follow_ups_created": 1,
611
+ "llm_calls_made": 1,
612
+ "summary": "..."
613
+ }
614
+ """
615
+ if not ticket_ids:
616
+ return {
617
+ "tickets_analyzed": 0,
618
+ "significant_tickets": 0,
619
+ "follow_ups_created": 0,
620
+ "llm_calls_made": 0,
621
+ "summary": "No tickets to analyze",
622
+ }
623
+
624
+ # Step 1: Gather context for ALL tickets (deterministic, 0 LLM calls)
625
+ from app.services.agent_tools import analyze_ticket_changes
626
+
627
+ tickets_context = []
628
+ for ticket_id in ticket_ids:
629
+ change_analysis = await analyze_ticket_changes.ainvoke(
630
+ {
631
+ "db": self.db,
632
+ "ticket_id": ticket_id,
633
+ }
634
+ )
635
+
636
+ # Parse JSON response
637
+ import json
638
+
639
+ parsed = json.loads(change_analysis)
640
+
641
+ if "error" not in parsed:
642
+ tickets_context.append(parsed)
643
+
644
+ if not tickets_context:
645
+ return {
646
+ "tickets_analyzed": len(ticket_ids),
647
+ "significant_tickets": 0,
648
+ "follow_ups_created": 0,
649
+ "llm_calls_made": 0,
650
+ "summary": "No valid tickets to analyze",
651
+ }
652
+
653
+ # Step 2: Apply deterministic filters (avoid LLM if possible)
654
+ # Only consider "significant" changes based on config threshold
655
+ from app.services.config_service import ConfigService
656
+
657
+ config = ConfigService().load_config()
658
+ significance_threshold = (
659
+ config.planner_config.udar.replan_significance_threshold
660
+ )
661
+
662
+ significant_tickets = [
663
+ t
664
+ for t in tickets_context
665
+ if t["file_count"] > significance_threshold or not t["verification_passed"]
666
+ ]
667
+
668
+ if not significant_tickets:
669
+ # Changes are too minor, skip LLM entirely
670
+ return {
671
+ "tickets_analyzed": len(tickets_context),
672
+ "significant_tickets": 0,
673
+ "follow_ups_created": 0,
674
+ "llm_calls_made": 0,
675
+ "summary": f"Analyzed {len(tickets_context)} tickets, all changes minor (<10 files, verification passed)",
676
+ }
677
+
678
+ # Step 3: Only call LLM for significant changes
679
+ # Build prompt for batched analysis
680
+ prompt = self._build_replan_prompt(significant_tickets)
681
+
682
+ # SINGLE batched LLM call for all significant tickets
683
+ response = await self.llm_adapter._acall(prompt)
684
+ llm_calls_made = 1
685
+
686
+ # Parse LLM response
687
+ parsed_response = self._parse_llm_response(response)
688
+ follow_ups = parsed_response.get("tickets", [])
689
+
690
+ # Step 4: Create follow-up tickets (deterministic)
691
+ from sqlalchemy import select as sa_select
692
+
693
+ created_count = 0
694
+ for follow_up_data in follow_ups:
695
+ # Get goal_id from first significant ticket
696
+ first_ticket_id = significant_tickets[0]["ticket_id"]
697
+ stmt = sa_select(Ticket).where(Ticket.id == first_ticket_id)
698
+ result = await self.db.execute(stmt)
699
+ original_ticket = result.scalar_one_or_none()
700
+
701
+ if original_ticket:
702
+ follow_up = Ticket(
703
+ goal_id=original_ticket.goal_id,
704
+ board_id=original_ticket.board_id,
705
+ title=follow_up_data.get("title", "Follow-up ticket"),
706
+ description=follow_up_data.get("description", ""),
707
+ state="proposed",
708
+ priority=follow_up_data.get("priority", 50),
709
+ )
710
+ self.db.add(follow_up)
711
+ created_count += 1
712
+
713
+ await self.db.commit()
714
+
715
+ return {
716
+ "tickets_analyzed": len(tickets_context),
717
+ "significant_tickets": len(significant_tickets),
718
+ "follow_ups_created": created_count,
719
+ "llm_calls_made": llm_calls_made,
720
+ "summary": f"Analyzed {len(tickets_context)} tickets, {len(significant_tickets)} significant, created {created_count} follow-ups",
721
+ }
722
+
723
+ def _build_replan_prompt(self, significant_tickets: list[dict]) -> str:
724
+ """Build prompt for batched replanning LLM call.
725
+
726
+ Args:
727
+ significant_tickets: List of ticket context dicts
728
+
729
+ Returns:
730
+ Prompt string for LLM
731
+ """
732
+ tickets_summary = "\n\n".join(
733
+ [
734
+ f"**Ticket {i + 1}: {t['ticket_title']}**\n"
735
+ f"- State: {t['state']}\n"
736
+ f"- Files changed: {t['file_count']}\n"
737
+ f"- Files: {', '.join(t['files_changed'][:5])}\n"
738
+ f"- Verification: {'✓ Passed' if t['verification_passed'] else '✗ Failed'}"
739
+ for i, t in enumerate(significant_tickets)
740
+ ]
741
+ )
742
+
743
+ prompt = f"""You are analyzing completed tickets to determine if follow-up work is needed.
744
+
745
+ **Completed Tickets:**
746
+ {tickets_summary}
747
+
748
+ Analyze these tickets and determine if any follow-up tickets are needed. Common reasons for follow-ups:
749
+ 1. Verification failed - need debugging/fixing
750
+ 2. Large changes (>10 files) - may need tests, documentation, or refactoring
751
+ 3. Core functionality added - may need integration with other parts
752
+
753
+ Only generate follow-ups if there's a clear, actionable need. Don't generate follow-ups for:
754
+ - Minor changes that are complete
755
+ - Tickets that already have tests
756
+ - Changes that are self-contained
757
+
758
+ Return JSON in this format:
759
+ {{
760
+ "reasoning": "Brief explanation of analysis",
761
+ "tickets": [
762
+ {{
763
+ "title": "Add tests for new authentication",
764
+ "description": "...",
765
+ "priority": 70
766
+ }}
767
+ ]
768
+ }}
769
+
770
+ If no follow-ups are needed, return {{"reasoning": "...", "tickets": []}}
771
+ """
772
+ return prompt
773
+
774
+ # Public API
775
+
776
+ async def generate_from_goal(
777
+ self,
778
+ goal_id: str,
779
+ fallback_to_legacy: bool = True,
780
+ timeout_seconds: int = 120,
781
+ ) -> dict:
782
+ """Generate tickets for a goal using UDAR agent (Phase 5: Production Hardened).
783
+
784
+ This is the main entry point for initial ticket generation with comprehensive
785
+ error handling, cost tracking, and graceful fallback to legacy mode.
786
+
787
+ Args:
788
+ goal_id: Goal ID to generate tickets for
789
+ fallback_to_legacy: If True, falls back to legacy on errors (default: True)
790
+ timeout_seconds: Timeout for UDAR execution (default: 120s)
791
+
792
+ Returns:
793
+ Dict with generated tickets and metadata:
794
+ {
795
+ "tickets": [...],
796
+ "summary": "Created 5 tickets",
797
+ "llm_calls_made": 1,
798
+ "phases_completed": ["understand", "decide", "act", "validate", "review"],
799
+ "used_legacy_fallback": false,
800
+ "cost_tracking": {...}
801
+ }
802
+
803
+ Raises:
804
+ ResourceNotFoundError: If goal not found
805
+ UDARAgentError: If UDAR fails and fallback disabled
806
+ """
807
+ import asyncio
808
+ import logging
809
+
810
+ from app.exceptions import (
811
+ LLMTimeoutError,
812
+ ResourceNotFoundError,
813
+ ToolExecutionError,
814
+ UDARAgentError,
815
+ )
816
+
817
+ logger = logging.getLogger(__name__)
818
+
819
+ # Get goal
820
+ goal = await self.db.get(Goal, goal_id)
821
+ if not goal:
822
+ raise ResourceNotFoundError("Goal", goal_id)
823
+
824
+ # Initialize state
825
+ initial_state: UDARState = {
826
+ "goal_id": goal_id,
827
+ "goal_title": goal.title,
828
+ "goal_description": goal.description or "",
829
+ "repo_root": goal.board.repo_root if goal.board else ".",
830
+ "trigger": "initial_generation",
831
+ "codebase_summary": None,
832
+ "existing_tickets": [],
833
+ "existing_ticket_count": 0,
834
+ "project_type": None,
835
+ "proposed_tickets": [],
836
+ "reasoning": "",
837
+ "should_generate_new": False,
838
+ "llm_calls_made": 0,
839
+ "validated_tickets": [],
840
+ "validation_results": [],
841
+ "final_tickets": [],
842
+ "review_summary": "",
843
+ "phase": "init",
844
+ "iteration": 0,
845
+ "errors": [],
846
+ "total_input_tokens": 0,
847
+ "total_output_tokens": 0,
848
+ }
849
+
850
+ # Try UDAR agent with comprehensive error handling
851
+ try:
852
+ # Run agent with timeout
853
+ result_state = await asyncio.wait_for(
854
+ self.agent.ainvoke(initial_state),
855
+ timeout=timeout_seconds,
856
+ )
857
+
858
+ # Track cost in AgentSession
859
+ await self._track_agent_session(goal_id, result_state)
860
+
861
+ # Return result
862
+ return {
863
+ "tickets": result_state["final_tickets"],
864
+ "summary": result_state["review_summary"],
865
+ "llm_calls_made": result_state["llm_calls_made"],
866
+ "phases_completed": [
867
+ "understand",
868
+ "decide",
869
+ "act",
870
+ "validate",
871
+ "review",
872
+ ],
873
+ "errors": result_state["errors"],
874
+ "used_legacy_fallback": False,
875
+ "cost_tracking": {
876
+ "input_tokens": result_state["total_input_tokens"],
877
+ "output_tokens": result_state["total_output_tokens"],
878
+ },
879
+ }
880
+
881
+ except TimeoutError as e:
882
+ # LLM timeout - fallback to legacy if enabled
883
+ logger.warning(
884
+ f"UDAR agent timeout after {timeout_seconds}s for goal {goal_id}, "
885
+ f"falling back to legacy: {fallback_to_legacy}"
886
+ )
887
+ if fallback_to_legacy:
888
+ return await self._fallback_to_legacy(goal_id, reason="timeout")
889
+ raise LLMTimeoutError("UDAR", timeout_seconds) from e
890
+
891
+ except ToolExecutionError as e:
892
+ # Tool execution failed - try partial results or fallback
893
+ logger.error(
894
+ f"UDAR tool execution failed in {e.phase} phase for goal {goal_id}: {e}"
895
+ )
896
+ if fallback_to_legacy:
897
+ return await self._fallback_to_legacy(
898
+ goal_id, reason=f"tool_error:{e.tool_name}"
899
+ )
900
+ raise
901
+
902
+ except UDARAgentError as e:
903
+ # UDAR-specific error - fallback
904
+ logger.error(f"UDAR agent error in {e.phase} phase for goal {goal_id}: {e}")
905
+ if fallback_to_legacy:
906
+ return await self._fallback_to_legacy(
907
+ goal_id, reason=f"udar_error:{e.phase}"
908
+ )
909
+ raise
910
+
911
+ except Exception as e:
912
+ # Unexpected error - always try fallback
913
+ logger.exception(f"Unexpected UDAR agent error for goal {goal_id}: {e}")
914
+ if fallback_to_legacy:
915
+ return await self._fallback_to_legacy(
916
+ goal_id, reason="unexpected_error"
917
+ )
918
+ raise UDARAgentError(f"Unexpected error: {str(e)}") from e
919
+
920
+ # Phase 5: Production Hardening - Helper Methods
921
+
922
+ async def _fallback_to_legacy(self, goal_id: str, reason: str) -> dict:
923
+ """Fallback to legacy ticket generation when UDAR fails.
924
+
925
+ Args:
926
+ goal_id: Goal ID to generate tickets for
927
+ reason: Reason for fallback (for logging/telemetry)
928
+
929
+ Returns:
930
+ Dict with legacy-generated tickets and metadata
931
+ """
932
+ import logging
933
+
934
+ from app.services.ticket_generation_service import TicketGenerationService
935
+
936
+ logger = logging.getLogger(__name__)
937
+
938
+ logger.info(
939
+ f"Falling back to legacy ticket generation for goal {goal_id}, "
940
+ f"reason: {reason}"
941
+ )
942
+
943
+ # Use legacy service
944
+ legacy_service = TicketGenerationService(db=self.db)
945
+ result = await legacy_service.generate_from_goal(goal_id=goal_id)
946
+
947
+ # Wrap result in UDAR-compatible format
948
+ return {
949
+ "tickets": result.get("tickets", []),
950
+ "summary": f"Generated {len(result.get('tickets', []))} tickets (legacy fallback)",
951
+ "llm_calls_made": 1, # Legacy uses 1 LLM call
952
+ "phases_completed": ["legacy"],
953
+ "errors": [f"UDAR fallback: {reason}"],
954
+ "used_legacy_fallback": True,
955
+ "fallback_reason": reason,
956
+ "cost_tracking": {
957
+ "input_tokens": 0, # Legacy doesn't track tokens
958
+ "output_tokens": 0,
959
+ },
960
+ }
961
+
962
+ async def _track_agent_session(self, goal_id: str, state: UDARState) -> None:
963
+ """Track UDAR agent session costs in database.
964
+
965
+ Logs cost info for observability. AgentSession records require a ticket_id
966
+ (FK to tickets), so UDAR goal-level sessions are logged but not persisted
967
+ to the agent_sessions table.
968
+
969
+ Args:
970
+ goal_id: Goal ID
971
+ state: Final UDAR state with token counts
972
+ """
973
+ import logging
974
+
975
+ from app.services.agent_registry import AGENT_REGISTRY, AgentType
976
+
977
+ logger = logging.getLogger(__name__)
978
+
979
+ try:
980
+ # Get agent pricing from registry
981
+ agent_config = AGENT_REGISTRY.get(AgentType.CLAUDE)
982
+
983
+ if not agent_config or not agent_config.cost_per_1k_input:
984
+ logger.warning(
985
+ "Claude agent config not found in registry, skipping cost tracking"
986
+ )
987
+ return
988
+
989
+ # Calculate cost
990
+ input_tokens = state.get("total_input_tokens", 0)
991
+ output_tokens = state.get("total_output_tokens", 0)
992
+
993
+ cost_usd = (input_tokens / 1000) * agent_config.cost_per_1k_input + (
994
+ output_tokens / 1000
995
+ ) * (agent_config.cost_per_1k_output or 0)
996
+
997
+ logger.info(
998
+ f"UDAR agent session for goal {goal_id}: "
999
+ f"{input_tokens} input tokens, {output_tokens} output tokens, "
1000
+ f"${cost_usd:.4f} estimated cost, "
1001
+ f"phases={state.get('phase', 'unknown')}, "
1002
+ f"llm_calls={state.get('llm_calls_made', 0)}"
1003
+ )
1004
+
1005
+ except Exception as e:
1006
+ # Don't fail request if cost tracking fails
1007
+ logger.error(f"Failed to track agent session cost for goal {goal_id}: {e}")