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,440 @@
|
|
|
1
|
+
"""End-to-end autonomous delivery pipeline orchestration.
|
|
2
|
+
|
|
3
|
+
This service orchestrates the complete workflow:
|
|
4
|
+
Goal → Tickets → Execute → Verify → PR → Review → Merge
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
from sqlalchemy.orm import selectinload
|
|
14
|
+
|
|
15
|
+
from app.exceptions import DraftError
|
|
16
|
+
from app.models.goal import Goal
|
|
17
|
+
from app.models.job import Job
|
|
18
|
+
from app.models.ticket import Ticket
|
|
19
|
+
from app.services.reliability_wrapper import ReliabilityWrapper, RetryConfig
|
|
20
|
+
from app.services.safe_autopilot import (
|
|
21
|
+
GateAction,
|
|
22
|
+
SafeAutopilot,
|
|
23
|
+
create_default_autopilot,
|
|
24
|
+
)
|
|
25
|
+
from app.state_machine import JobStatus, TicketState
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PipelineResult:
|
|
32
|
+
"""Result of running the full delivery pipeline."""
|
|
33
|
+
|
|
34
|
+
status: str # "ready_for_merge", "blocked", "in_progress"
|
|
35
|
+
reason: str | None = None
|
|
36
|
+
tickets_completed: list[str] = None
|
|
37
|
+
tickets_blocked: list[str] = None
|
|
38
|
+
pr_url: str | None = None
|
|
39
|
+
checklist_id: str | None = None
|
|
40
|
+
evidence: dict[str, Any] = None
|
|
41
|
+
total_cost_usd: float = 0.0
|
|
42
|
+
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
if self.tickets_completed is None:
|
|
45
|
+
self.tickets_completed = []
|
|
46
|
+
if self.tickets_blocked is None:
|
|
47
|
+
self.tickets_blocked = []
|
|
48
|
+
if self.evidence is None:
|
|
49
|
+
self.evidence = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PipelineError(DraftError):
|
|
53
|
+
"""Error during pipeline execution."""
|
|
54
|
+
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DeliveryPipeline:
|
|
59
|
+
"""
|
|
60
|
+
Orchestrates the complete autonomous delivery workflow.
|
|
61
|
+
|
|
62
|
+
This is the "autopilot" that takes a goal and delivers merge-ready code.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
db: AsyncSession,
|
|
68
|
+
retry_config: RetryConfig | None = None,
|
|
69
|
+
autopilot: SafeAutopilot | None = None,
|
|
70
|
+
):
|
|
71
|
+
self.db = db
|
|
72
|
+
self.reliability_wrapper = ReliabilityWrapper(
|
|
73
|
+
db=db, retry_config=retry_config or RetryConfig(max_retries=3)
|
|
74
|
+
)
|
|
75
|
+
self.autopilot = autopilot or create_default_autopilot(db)
|
|
76
|
+
|
|
77
|
+
async def run_full_pipeline(
|
|
78
|
+
self, goal_id: str, auto_approve: bool = False, dry_run: bool = False
|
|
79
|
+
) -> PipelineResult:
|
|
80
|
+
"""Run the complete pipeline from goal to merge-ready state.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
goal_id: The goal to deliver
|
|
84
|
+
auto_approve: If True, automatically approve all steps (YOLO mode)
|
|
85
|
+
dry_run: If True, simulate but don't actually execute
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
PipelineResult with status and details
|
|
89
|
+
"""
|
|
90
|
+
logger.info(
|
|
91
|
+
f"Starting delivery pipeline for goal {goal_id} (auto_approve={auto_approve}, dry_run={dry_run})"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Stage 1: Validate goal exists and get tickets
|
|
96
|
+
goal, tickets = await self._validate_and_load(goal_id)
|
|
97
|
+
|
|
98
|
+
if not tickets:
|
|
99
|
+
return PipelineResult(
|
|
100
|
+
status="blocked",
|
|
101
|
+
reason="No tickets found for this goal. Generate tickets first.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Stage 2: Topologically sort tickets by dependencies
|
|
105
|
+
sorted_tickets = self._topological_sort(tickets)
|
|
106
|
+
logger.info(f"Execution order: {[t.id for t in sorted_tickets]}")
|
|
107
|
+
|
|
108
|
+
# Stage 3: Execute tickets in order with safety gates
|
|
109
|
+
completed = []
|
|
110
|
+
blocked = []
|
|
111
|
+
|
|
112
|
+
for ticket in sorted_tickets:
|
|
113
|
+
if dry_run:
|
|
114
|
+
logger.info(
|
|
115
|
+
f"[DRY RUN] Would execute ticket {ticket.id}: {ticket.title}"
|
|
116
|
+
)
|
|
117
|
+
completed.append(ticket.id)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Execute ticket
|
|
121
|
+
result = await self._execute_with_retry(ticket, max_retries=2)
|
|
122
|
+
|
|
123
|
+
if result["status"] == "success":
|
|
124
|
+
completed.append(ticket.id)
|
|
125
|
+
|
|
126
|
+
# Check safety gates after successful execution
|
|
127
|
+
can_continue, gate_results = await self.autopilot.should_continue(
|
|
128
|
+
ticket
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not can_continue:
|
|
132
|
+
# Find blocking or pausing gates
|
|
133
|
+
blocking_gates = [
|
|
134
|
+
r
|
|
135
|
+
for r in gate_results
|
|
136
|
+
if not r.passed
|
|
137
|
+
and r.action in [GateAction.BLOCK, GateAction.PAUSE]
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
reasons = [f"{r.gate_name}: {r.reason}" for r in blocking_gates]
|
|
141
|
+
|
|
142
|
+
logger.warning(
|
|
143
|
+
f"Safety gates triggered for ticket {ticket.id}: {', '.join(reasons)}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not auto_approve:
|
|
147
|
+
return PipelineResult(
|
|
148
|
+
status="blocked",
|
|
149
|
+
reason=f"Safety gates failed: {', '.join(reasons)}",
|
|
150
|
+
tickets_completed=completed,
|
|
151
|
+
tickets_blocked=[ticket.id],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Log any alert-level gate failures
|
|
155
|
+
alert_gates = [
|
|
156
|
+
r
|
|
157
|
+
for r in gate_results
|
|
158
|
+
if not r.passed and r.action == GateAction.ALERT
|
|
159
|
+
]
|
|
160
|
+
for alert in alert_gates:
|
|
161
|
+
logger.warning(
|
|
162
|
+
f"Gate alert for ticket {ticket.id}: {alert.reason}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
else:
|
|
166
|
+
blocked.append(ticket.id)
|
|
167
|
+
# If one ticket fails and not auto_approve, stop here
|
|
168
|
+
if not auto_approve:
|
|
169
|
+
return PipelineResult(
|
|
170
|
+
status="blocked",
|
|
171
|
+
reason=f"Ticket {ticket.id} failed: {result.get('reason')}",
|
|
172
|
+
tickets_completed=completed,
|
|
173
|
+
tickets_blocked=blocked,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Stage 4: Verify all tickets passed
|
|
177
|
+
if not dry_run:
|
|
178
|
+
verification = await self._verify_all(tickets)
|
|
179
|
+
if not verification["passed"]:
|
|
180
|
+
return PipelineResult(
|
|
181
|
+
status="blocked",
|
|
182
|
+
reason=f"Verification failed: {verification['failures']}",
|
|
183
|
+
tickets_completed=completed,
|
|
184
|
+
tickets_blocked=blocked,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Stage 5: Collect evidence for review
|
|
188
|
+
evidence = await self._collect_evidence(tickets)
|
|
189
|
+
|
|
190
|
+
# Stage 6: Calculate total cost
|
|
191
|
+
total_cost = await self._calculate_total_cost(goal_id)
|
|
192
|
+
|
|
193
|
+
return PipelineResult(
|
|
194
|
+
status="ready_for_merge",
|
|
195
|
+
tickets_completed=completed,
|
|
196
|
+
evidence=evidence,
|
|
197
|
+
total_cost_usd=total_cost,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.exception(f"Pipeline failed for goal {goal_id}")
|
|
202
|
+
raise PipelineError(f"Pipeline execution failed: {str(e)}") from e
|
|
203
|
+
|
|
204
|
+
async def _validate_and_load(self, goal_id: str) -> tuple[Goal, list[Ticket]]:
|
|
205
|
+
"""Validate goal exists and load all its tickets."""
|
|
206
|
+
result = await self.db.execute(
|
|
207
|
+
select(Goal).where(Goal.id == goal_id).options(selectinload(Goal.tickets))
|
|
208
|
+
)
|
|
209
|
+
goal = result.scalar_one_or_none()
|
|
210
|
+
|
|
211
|
+
if not goal:
|
|
212
|
+
raise PipelineError(f"Goal {goal_id} not found")
|
|
213
|
+
|
|
214
|
+
# Get all tickets for this goal
|
|
215
|
+
ticket_result = await self.db.execute(
|
|
216
|
+
select(Ticket)
|
|
217
|
+
.where(Ticket.goal_id == goal_id)
|
|
218
|
+
.options(selectinload(Ticket.jobs))
|
|
219
|
+
)
|
|
220
|
+
tickets = list(ticket_result.scalars().all())
|
|
221
|
+
|
|
222
|
+
return goal, tickets
|
|
223
|
+
|
|
224
|
+
def _topological_sort(self, tickets: list[Ticket]) -> list[Ticket]:
|
|
225
|
+
"""Sort tickets by dependencies (topological order).
|
|
226
|
+
|
|
227
|
+
Tickets with no dependencies come first.
|
|
228
|
+
Blocked tickets come after their blockers.
|
|
229
|
+
"""
|
|
230
|
+
# Build dependency graph
|
|
231
|
+
ticket_map = {t.id: t for t in tickets}
|
|
232
|
+
in_degree = {t.id: 0 for t in tickets}
|
|
233
|
+
graph = {t.id: [] for t in tickets}
|
|
234
|
+
|
|
235
|
+
for ticket in tickets:
|
|
236
|
+
if ticket.blocked_by_ticket_id:
|
|
237
|
+
if ticket.blocked_by_ticket_id in graph:
|
|
238
|
+
graph[ticket.blocked_by_ticket_id].append(ticket.id)
|
|
239
|
+
in_degree[ticket.id] += 1
|
|
240
|
+
|
|
241
|
+
# Kahn's algorithm for topological sort
|
|
242
|
+
queue = [tid for tid, degree in in_degree.items() if degree == 0]
|
|
243
|
+
sorted_ids = []
|
|
244
|
+
|
|
245
|
+
while queue:
|
|
246
|
+
# Sort by priority within the queue
|
|
247
|
+
queue.sort(key=lambda tid: ticket_map[tid].priority or 0, reverse=True)
|
|
248
|
+
current = queue.pop(0)
|
|
249
|
+
sorted_ids.append(current)
|
|
250
|
+
|
|
251
|
+
for neighbor in graph[current]:
|
|
252
|
+
in_degree[neighbor] -= 1
|
|
253
|
+
if in_degree[neighbor] == 0:
|
|
254
|
+
queue.append(neighbor)
|
|
255
|
+
|
|
256
|
+
# Check for cycles
|
|
257
|
+
if len(sorted_ids) != len(tickets):
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Cycle detected in ticket dependencies, using original order"
|
|
260
|
+
)
|
|
261
|
+
return tickets
|
|
262
|
+
|
|
263
|
+
return [ticket_map[tid] for tid in sorted_ids]
|
|
264
|
+
|
|
265
|
+
async def _execute_with_retry(
|
|
266
|
+
self, ticket: Ticket, max_retries: int = 2
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
"""Execute a ticket with automatic retry, checkpointing, and recovery.
|
|
269
|
+
|
|
270
|
+
Uses ReliabilityWrapper for robust execution with:
|
|
271
|
+
- Exponential backoff retry
|
|
272
|
+
- Checkpointing for resume capability
|
|
273
|
+
- Intelligent error classification
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Dict with status and reason
|
|
277
|
+
"""
|
|
278
|
+
from app.services.job_service import JobService
|
|
279
|
+
|
|
280
|
+
# Check if ticket is in correct state
|
|
281
|
+
if ticket.state not in [TicketState.PLANNED.value, TicketState.BLOCKED.value]:
|
|
282
|
+
return {
|
|
283
|
+
"status": "skipped",
|
|
284
|
+
"reason": f"Ticket in state {ticket.state}, not ready for execution",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
job_service = JobService(self.db)
|
|
288
|
+
|
|
289
|
+
async def execute_ticket_with_job():
|
|
290
|
+
"""Inner function that creates job and executes ticket."""
|
|
291
|
+
# Create execution job
|
|
292
|
+
job = await job_service.create_job(
|
|
293
|
+
ticket_id=ticket.id, job_type="execute", board_id=ticket.board_id
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
logger.info(f"Executing ticket {ticket.id} with job {job.id}")
|
|
297
|
+
|
|
298
|
+
# TODO: Actually call the Celery task and wait for completion
|
|
299
|
+
# For now, just mark as success if job created
|
|
300
|
+
|
|
301
|
+
return {"status": "success", "job_id": job.id}
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Execute with reliability wrapper (automatic retry, checkpointing)
|
|
305
|
+
result = await self.reliability_wrapper.execute_with_reliability(
|
|
306
|
+
func=execute_ticket_with_job,
|
|
307
|
+
ticket_id=ticket.id,
|
|
308
|
+
job_id=None, # Job created inside function
|
|
309
|
+
checkpoint_key=f"pipeline:execute:{ticket.id}",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logger.error(f"Ticket {ticket.id} execution failed after all retries: {e}")
|
|
316
|
+
return {"status": "failed", "reason": str(e)}
|
|
317
|
+
|
|
318
|
+
async def _verify_all(self, tickets: list[Ticket]) -> dict[str, Any]:
|
|
319
|
+
"""Run verification for all tickets and aggregate results.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Dict with passed flag and list of failures
|
|
323
|
+
"""
|
|
324
|
+
from app.services.job_service import JobService
|
|
325
|
+
|
|
326
|
+
JobService(self.db)
|
|
327
|
+
failures = []
|
|
328
|
+
|
|
329
|
+
for ticket in tickets:
|
|
330
|
+
# Get most recent verification job
|
|
331
|
+
result = await self.db.execute(
|
|
332
|
+
select(Job)
|
|
333
|
+
.where(Job.ticket_id == ticket.id)
|
|
334
|
+
.where(Job.job_type == "verify")
|
|
335
|
+
.order_by(Job.created_at.desc())
|
|
336
|
+
.limit(1)
|
|
337
|
+
)
|
|
338
|
+
verify_job = result.scalar_one_or_none()
|
|
339
|
+
|
|
340
|
+
if not verify_job:
|
|
341
|
+
failures.append(f"Ticket {ticket.id}: No verification run")
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if verify_job.status != JobStatus.SUCCEEDED.value:
|
|
345
|
+
failures.append(f"Ticket {ticket.id}: Verification {verify_job.status}")
|
|
346
|
+
|
|
347
|
+
return {"passed": len(failures) == 0, "failures": failures}
|
|
348
|
+
|
|
349
|
+
async def _collect_evidence(self, tickets: list[Ticket]) -> dict[str, Any]:
|
|
350
|
+
"""Collect all evidence (diffs, tests, logs) for tickets.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Dict with evidence summary
|
|
354
|
+
"""
|
|
355
|
+
evidence = {
|
|
356
|
+
"total_tickets": len(tickets),
|
|
357
|
+
"files_changed": [],
|
|
358
|
+
"tests_run": 0,
|
|
359
|
+
"tests_passed": 0,
|
|
360
|
+
"diffs": [],
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for ticket in tickets:
|
|
364
|
+
# Get all jobs for this ticket
|
|
365
|
+
result = await self.db.execute(
|
|
366
|
+
select(Job)
|
|
367
|
+
.where(Job.ticket_id == ticket.id)
|
|
368
|
+
.options(selectinload(Job.evidence))
|
|
369
|
+
)
|
|
370
|
+
jobs = list(result.scalars().all())
|
|
371
|
+
|
|
372
|
+
for job in jobs:
|
|
373
|
+
if job.evidence:
|
|
374
|
+
evidence["diffs"].extend(
|
|
375
|
+
[
|
|
376
|
+
{
|
|
377
|
+
"ticket_id": ticket.id,
|
|
378
|
+
"job_id": job.id,
|
|
379
|
+
"evidence_id": e.id,
|
|
380
|
+
"type": e.evidence_type,
|
|
381
|
+
}
|
|
382
|
+
for e in job.evidence
|
|
383
|
+
]
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return evidence
|
|
387
|
+
|
|
388
|
+
async def _calculate_total_cost(self, goal_id: str) -> float:
|
|
389
|
+
"""Calculate total LLM API cost for all tickets in goal.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Total cost in USD
|
|
393
|
+
"""
|
|
394
|
+
# TODO: Implement cost tracking aggregation
|
|
395
|
+
# For now, return 0
|
|
396
|
+
return 0.0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def get_pipeline_status(db: AsyncSession, goal_id: str) -> dict[str, Any]:
|
|
400
|
+
"""Get the current status of the delivery pipeline for a goal.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Dict with pipeline status, progress, and blocking issues
|
|
404
|
+
"""
|
|
405
|
+
result = await db.execute(select(Ticket).where(Ticket.goal_id == goal_id))
|
|
406
|
+
tickets = list(result.scalars().all())
|
|
407
|
+
|
|
408
|
+
if not tickets:
|
|
409
|
+
return {"status": "not_started", "reason": "No tickets generated yet"}
|
|
410
|
+
|
|
411
|
+
state_counts = {}
|
|
412
|
+
for ticket in tickets:
|
|
413
|
+
state = ticket.state
|
|
414
|
+
state_counts[state] = state_counts.get(state, 0) + 1
|
|
415
|
+
|
|
416
|
+
total = len(tickets)
|
|
417
|
+
completed = state_counts.get(TicketState.DONE.value, 0)
|
|
418
|
+
blocked = state_counts.get(TicketState.BLOCKED.value, 0)
|
|
419
|
+
executing = state_counts.get(TicketState.EXECUTING.value, 0) + state_counts.get(
|
|
420
|
+
TicketState.VERIFYING.value, 0
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if blocked > 0:
|
|
424
|
+
status = "blocked"
|
|
425
|
+
elif executing > 0:
|
|
426
|
+
status = "in_progress"
|
|
427
|
+
elif completed == total:
|
|
428
|
+
status = "ready_for_merge"
|
|
429
|
+
else:
|
|
430
|
+
status = "ready_to_execute"
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"status": status,
|
|
434
|
+
"total_tickets": total,
|
|
435
|
+
"completed": completed,
|
|
436
|
+
"blocked": blocked,
|
|
437
|
+
"executing": executing,
|
|
438
|
+
"progress_percent": int((completed / total) * 100) if total > 0 else 0,
|
|
439
|
+
"state_breakdown": state_counts,
|
|
440
|
+
}
|