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,318 @@
1
+ """Enhanced agent session management with database persistence and cost tracking.
2
+
3
+ This module provides database-backed session management for:
4
+ - Conversation continuity (session resume)
5
+ - Cost tracking per session
6
+ - Multi-agent support via the agent registry
7
+ """
8
+
9
+ import logging
10
+ from datetime import datetime
11
+ from uuid import uuid4
12
+
13
+ from sqlalchemy import select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.models.agent_session import AgentMessage, AgentSession
17
+ from app.services.agent_registry import (
18
+ AGENT_REGISTRY,
19
+ AgentType,
20
+ )
21
+ from app.services.cost_tracking_service import CostTrackingService, TokenUsage
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AgentSessionManager:
27
+ """Manages agent sessions with database persistence and cost tracking."""
28
+
29
+ def __init__(self, db: AsyncSession):
30
+ self.db = db
31
+ self.cost_service = CostTrackingService()
32
+
33
+ async def create_session(
34
+ self,
35
+ ticket_id: str,
36
+ agent_type: str,
37
+ job_id: str | None = None,
38
+ external_session_id: str | None = None,
39
+ metadata: dict | None = None,
40
+ ) -> AgentSession:
41
+ """Create a new agent session.
42
+
43
+ Args:
44
+ ticket_id: The ticket this session is for
45
+ agent_type: Type of agent (claude, amp, cursor, etc.)
46
+ job_id: Optional job ID
47
+ external_session_id: Optional external session ID from the agent
48
+ metadata: Optional metadata dict
49
+
50
+ Returns:
51
+ The created AgentSession
52
+ """
53
+ session = AgentSession(
54
+ id=str(uuid4()),
55
+ ticket_id=ticket_id,
56
+ job_id=job_id,
57
+ agent_type=agent_type,
58
+ agent_session_id=external_session_id,
59
+ is_active=True,
60
+ turn_count=0,
61
+ total_input_tokens=0,
62
+ total_output_tokens=0,
63
+ estimated_cost_usd=0.0,
64
+ metadata_=metadata,
65
+ )
66
+
67
+ self.db.add(session)
68
+ await self.db.flush()
69
+
70
+ logger.info(
71
+ f"Created agent session {session.id[:8]}... for ticket {ticket_id[:8]}... "
72
+ f"using {agent_type}"
73
+ )
74
+
75
+ return session
76
+
77
+ async def get_active_session(
78
+ self,
79
+ ticket_id: str,
80
+ agent_type: str | None = None,
81
+ ) -> AgentSession | None:
82
+ """Get the active session for a ticket.
83
+
84
+ Args:
85
+ ticket_id: The ticket to get session for
86
+ agent_type: Optional agent type filter
87
+
88
+ Returns:
89
+ The active AgentSession if one exists
90
+ """
91
+ query = select(AgentSession).where(
92
+ AgentSession.ticket_id == ticket_id, AgentSession.is_active
93
+ )
94
+
95
+ if agent_type:
96
+ query = query.where(AgentSession.agent_type == agent_type)
97
+
98
+ query = query.order_by(AgentSession.updated_at.desc())
99
+
100
+ result = await self.db.execute(query)
101
+ return result.scalar_one_or_none()
102
+
103
+ async def get_or_create_session(
104
+ self,
105
+ ticket_id: str,
106
+ agent_type: str,
107
+ job_id: str | None = None,
108
+ ) -> tuple[AgentSession, bool]:
109
+ """Get existing active session or create new one.
110
+
111
+ Args:
112
+ ticket_id: The ticket ID
113
+ agent_type: The agent type
114
+ job_id: Optional job ID
115
+
116
+ Returns:
117
+ Tuple of (session, is_new)
118
+ """
119
+ existing = await self.get_active_session(ticket_id, agent_type)
120
+ if existing:
121
+ return existing, False
122
+
123
+ session = await self.create_session(
124
+ ticket_id=ticket_id,
125
+ agent_type=agent_type,
126
+ job_id=job_id,
127
+ )
128
+ return session, True
129
+
130
+ async def record_turn(
131
+ self,
132
+ session_id: str,
133
+ prompt: str,
134
+ response: str,
135
+ input_tokens: int = 0,
136
+ output_tokens: int = 0,
137
+ tool_name: str | None = None,
138
+ tool_input: dict | None = None,
139
+ tool_output: str | None = None,
140
+ ) -> tuple[AgentSession, float]:
141
+ """Record a conversation turn with cost tracking.
142
+
143
+ Args:
144
+ session_id: The session ID
145
+ prompt: The user prompt
146
+ response: The assistant response
147
+ input_tokens: Number of input tokens
148
+ output_tokens: Number of output tokens
149
+ tool_name: Optional tool that was used
150
+ tool_input: Optional tool input
151
+ tool_output: Optional tool output
152
+
153
+ Returns:
154
+ Tuple of (updated_session, turn_cost)
155
+ """
156
+ result = await self.db.execute(
157
+ select(AgentSession).where(AgentSession.id == session_id)
158
+ )
159
+ session = result.scalar_one_or_none()
160
+
161
+ if not session:
162
+ raise ValueError(f"Session not found: {session_id}")
163
+
164
+ # Calculate cost
165
+ try:
166
+ agent_type = AgentType(session.agent_type)
167
+ config = AGENT_REGISTRY.get(agent_type)
168
+ if config and config.cost_per_1k_input and config.cost_per_1k_output:
169
+ usage = TokenUsage(
170
+ input_tokens=input_tokens, output_tokens=output_tokens
171
+ )
172
+ turn_cost = self.cost_service.calculate_cost(
173
+ usage, config.cost_per_1k_input, config.cost_per_1k_output
174
+ )
175
+ else:
176
+ turn_cost = 0.0
177
+ except (ValueError, KeyError):
178
+ turn_cost = 0.0
179
+
180
+ # Update session
181
+ session.turn_count += 1
182
+ session.total_input_tokens += input_tokens
183
+ session.total_output_tokens += output_tokens
184
+ session.estimated_cost_usd += turn_cost
185
+ session.last_prompt = prompt[:2000] if prompt else None # Truncate for storage
186
+ session.last_response_summary = (
187
+ response[:500] if response else None
188
+ ) # Summary only
189
+ session.updated_at = datetime.utcnow()
190
+
191
+ # Create message record for user prompt
192
+ user_message = AgentMessage(
193
+ id=str(uuid4()),
194
+ session_id=session_id,
195
+ role="user",
196
+ content=prompt,
197
+ input_tokens=input_tokens,
198
+ output_tokens=0,
199
+ )
200
+ self.db.add(user_message)
201
+
202
+ # Create message record for assistant response
203
+ assistant_message = AgentMessage(
204
+ id=str(uuid4()),
205
+ session_id=session_id,
206
+ role="assistant",
207
+ content=response,
208
+ input_tokens=0,
209
+ output_tokens=output_tokens,
210
+ tool_name=tool_name,
211
+ tool_input=tool_input,
212
+ tool_output=tool_output,
213
+ )
214
+ self.db.add(assistant_message)
215
+
216
+ await self.db.flush()
217
+
218
+ logger.info(
219
+ f"Recorded turn {session.turn_count} for session {session_id[:8]}...: "
220
+ f"{input_tokens} in, {output_tokens} out, ${turn_cost:.4f}"
221
+ )
222
+
223
+ return session, turn_cost
224
+
225
+ async def end_session(self, session_id: str) -> AgentSession:
226
+ """End a session and mark it inactive.
227
+
228
+ Args:
229
+ session_id: The session ID
230
+
231
+ Returns:
232
+ The ended session
233
+ """
234
+ result = await self.db.execute(
235
+ select(AgentSession).where(AgentSession.id == session_id)
236
+ )
237
+ session = result.scalar_one_or_none()
238
+
239
+ if not session:
240
+ raise ValueError(f"Session not found: {session_id}")
241
+
242
+ session.is_active = False
243
+ session.ended_at = datetime.utcnow()
244
+
245
+ await self.db.flush()
246
+
247
+ logger.info(
248
+ f"Ended session {session_id[:8]}...: "
249
+ f"{session.turn_count} turns, ${session.estimated_cost_usd:.4f} total"
250
+ )
251
+
252
+ return session
253
+
254
+ async def get_session_history(
255
+ self,
256
+ ticket_id: str,
257
+ limit: int = 10,
258
+ ) -> list[AgentSession]:
259
+ """Get session history for a ticket.
260
+
261
+ Args:
262
+ ticket_id: The ticket ID
263
+ limit: Maximum number of sessions to return
264
+
265
+ Returns:
266
+ List of sessions ordered by most recent first
267
+ """
268
+ result = await self.db.execute(
269
+ select(AgentSession)
270
+ .where(AgentSession.ticket_id == ticket_id)
271
+ .order_by(AgentSession.created_at.desc())
272
+ .limit(limit)
273
+ )
274
+ return list(result.scalars().all())
275
+
276
+ async def get_ticket_total_cost(self, ticket_id: str) -> float:
277
+ """Get total cost across all sessions for a ticket.
278
+
279
+ Args:
280
+ ticket_id: The ticket ID
281
+
282
+ Returns:
283
+ Total cost in USD
284
+ """
285
+ sessions = await self.get_session_history(ticket_id, limit=1000)
286
+ return sum(s.estimated_cost_usd for s in sessions)
287
+
288
+
289
+ def get_resumable_session_args(
290
+ session: AgentSession | None,
291
+ agent_type: str,
292
+ ) -> dict:
293
+ """Get command-line args for resuming a session if supported.
294
+
295
+ Args:
296
+ session: Optional existing session
297
+ agent_type: The agent type
298
+
299
+ Returns:
300
+ Dict of args to add to command (empty if no resume support)
301
+ """
302
+ if not session or not session.agent_session_id:
303
+ return {}
304
+
305
+ try:
306
+ agent_enum = AgentType(agent_type)
307
+ config = AGENT_REGISTRY.get(agent_enum)
308
+
309
+ if config and config.supports_session_resume:
310
+ if agent_enum == AgentType.CLAUDE:
311
+ return {"--resume": session.agent_session_id}
312
+ elif agent_enum == AgentType.AMP:
313
+ return {"--thread": session.agent_session_id}
314
+
315
+ except (ValueError, KeyError):
316
+ pass
317
+
318
+ return {}
@@ -0,0 +1,219 @@
1
+ """Agent session continuity service.
2
+
3
+ Tracks Claude CLI session IDs to enable multi-turn conversations across executions.
4
+ When the same ticket executes multiple times, the agent can continue from where
5
+ it left off instead of starting fresh.
6
+
7
+ Session IDs are stored per-worktree in .draft/agent_session.json
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ SESSION_DIR = ".draft"
20
+ SESSION_FILE = "agent_session.json"
21
+
22
+ # Regex patterns to extract session ID from Claude CLI output
23
+ # Claude CLI outputs session info in various formats
24
+ SESSION_PATTERNS = [
25
+ # "Session: abc123-def456"
26
+ r"Session:\s*([a-zA-Z0-9_-]+)",
27
+ # "Continuing session abc123-def456"
28
+ r"Continuing session\s+([a-zA-Z0-9_-]+)",
29
+ # "session_id: abc123-def456"
30
+ r"session_id:\s*['\"]?([a-zA-Z0-9_-]+)['\"]?",
31
+ # JSON output: {"session_id": "abc123"}
32
+ r'"session_id"\s*:\s*"([a-zA-Z0-9_-]+)"',
33
+ # "--resume abc123" flag echoed
34
+ r"--resume\s+([a-zA-Z0-9_-]+)",
35
+ ]
36
+
37
+
38
+ @dataclass
39
+ class AgentSession:
40
+ """Represents a stored agent session."""
41
+
42
+ session_id: str
43
+ agent_type: str # "claude", "cursor", etc.
44
+ ticket_id: str
45
+ created_at: datetime
46
+ last_used_at: datetime
47
+ execution_count: int = 1
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "session_id": self.session_id,
52
+ "agent_type": self.agent_type,
53
+ "ticket_id": self.ticket_id,
54
+ "created_at": self.created_at.isoformat(),
55
+ "last_used_at": self.last_used_at.isoformat(),
56
+ "execution_count": self.execution_count,
57
+ }
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: dict) -> "AgentSession":
61
+ return cls(
62
+ session_id=data["session_id"],
63
+ agent_type=data["agent_type"],
64
+ ticket_id=data["ticket_id"],
65
+ created_at=datetime.fromisoformat(data["created_at"]),
66
+ last_used_at=datetime.fromisoformat(data["last_used_at"]),
67
+ execution_count=data.get("execution_count", 1),
68
+ )
69
+
70
+
71
+ class AgentSessionService:
72
+ """Manages agent session continuity for a worktree."""
73
+
74
+ def __init__(self, worktree_path: Path):
75
+ self.worktree_path = worktree_path
76
+ self.session_dir = worktree_path / SESSION_DIR
77
+ self.session_file = self.session_dir / SESSION_FILE
78
+
79
+ def _ensure_dir(self) -> None:
80
+ """Ensure the session directory exists."""
81
+ self.session_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Add to .gitignore if not already
84
+ gitignore = self.worktree_path / ".gitignore"
85
+ marker = f"/{SESSION_DIR}/"
86
+ if gitignore.exists():
87
+ content = gitignore.read_text()
88
+ if marker not in content:
89
+ with open(gitignore, "a") as f:
90
+ f.write(f"\n# Draft session data\n{marker}\n")
91
+ else:
92
+ gitignore.write_text(f"# Draft session data\n{marker}\n")
93
+
94
+ def get_session(self, ticket_id: str) -> AgentSession | None:
95
+ """Get the stored session for a ticket.
96
+
97
+ Args:
98
+ ticket_id: The ticket ID to get session for
99
+
100
+ Returns:
101
+ AgentSession if found and matches ticket, None otherwise
102
+ """
103
+ if not self.session_file.exists():
104
+ return None
105
+
106
+ try:
107
+ data = json.loads(self.session_file.read_text())
108
+ session = AgentSession.from_dict(data)
109
+
110
+ # Only return if it's for the same ticket
111
+ if session.ticket_id == ticket_id:
112
+ return session
113
+
114
+ logger.debug(
115
+ f"Session exists but for different ticket ({session.ticket_id} != {ticket_id})"
116
+ )
117
+ return None
118
+
119
+ except (json.JSONDecodeError, KeyError) as e:
120
+ logger.warning(f"Failed to read session file: {e}")
121
+ return None
122
+
123
+ def save_session(
124
+ self,
125
+ session_id: str,
126
+ ticket_id: str,
127
+ agent_type: str = "claude",
128
+ ) -> AgentSession:
129
+ """Save or update a session.
130
+
131
+ Args:
132
+ session_id: The agent's session ID
133
+ ticket_id: The ticket this session is for
134
+ agent_type: Type of agent ("claude", "cursor", etc.)
135
+
136
+ Returns:
137
+ The saved AgentSession
138
+ """
139
+ self._ensure_dir()
140
+
141
+ now = datetime.now(UTC)
142
+
143
+ # Check if updating existing session
144
+ existing = self.get_session(ticket_id)
145
+ if existing and existing.session_id == session_id:
146
+ existing.last_used_at = now
147
+ existing.execution_count += 1
148
+ session = existing
149
+ else:
150
+ session = AgentSession(
151
+ session_id=session_id,
152
+ agent_type=agent_type,
153
+ ticket_id=ticket_id,
154
+ created_at=now,
155
+ last_used_at=now,
156
+ execution_count=1,
157
+ )
158
+
159
+ self.session_file.write_text(json.dumps(session.to_dict(), indent=2))
160
+ logger.info(
161
+ f"Saved session {session_id} for ticket {ticket_id} (count: {session.execution_count})"
162
+ )
163
+
164
+ return session
165
+
166
+ def clear_session(self) -> None:
167
+ """Clear the stored session (e.g., when ticket is done)."""
168
+ if self.session_file.exists():
169
+ self.session_file.unlink()
170
+ logger.info("Cleared agent session")
171
+
172
+ def extract_session_id_from_output(self, output: str) -> str | None:
173
+ """Extract session ID from agent CLI output.
174
+
175
+ Parses various output formats to find the session ID.
176
+
177
+ Args:
178
+ output: The stdout/stderr from the agent CLI
179
+
180
+ Returns:
181
+ Session ID if found, None otherwise
182
+ """
183
+ for pattern in SESSION_PATTERNS:
184
+ match = re.search(pattern, output, re.IGNORECASE)
185
+ if match:
186
+ session_id = match.group(1)
187
+ logger.debug(f"Extracted session ID: {session_id}")
188
+ return session_id
189
+
190
+ return None
191
+
192
+ def get_continue_flag(
193
+ self, ticket_id: str, agent_type: str = "claude"
194
+ ) -> str | None:
195
+ """Get the CLI flag to continue an existing session.
196
+
197
+ Args:
198
+ ticket_id: The ticket ID
199
+ agent_type: Type of agent
200
+
201
+ Returns:
202
+ CLI flag string if session exists (e.g., "--resume abc123"), None otherwise
203
+ """
204
+ session = self.get_session(ticket_id)
205
+ if not session:
206
+ return None
207
+
208
+ if agent_type == "claude":
209
+ return f"--resume {session.session_id}"
210
+ elif agent_type == "cursor":
211
+ # Cursor uses different flag
212
+ return f"--continue {session.session_id}"
213
+
214
+ return None
215
+
216
+
217
+ def get_session_service(worktree_path: Path) -> AgentSessionService:
218
+ """Factory function to get session service for a worktree."""
219
+ return AgentSessionService(worktree_path)