draft-board 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,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)
|