codeframe-ai 0.9.0__py3-none-any.whl
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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
"""Runtime/orchestration for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
Manages task execution runs and the agent loop.
|
|
4
|
+
|
|
5
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING, Optional
|
|
14
|
+
|
|
15
|
+
from codeframe.core import engine_stats, events, tasks
|
|
16
|
+
from codeframe.core.state_machine import TaskStatus
|
|
17
|
+
from codeframe.core.workspace import Workspace, get_db_connection
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from codeframe.core.agent import AgentState
|
|
23
|
+
from codeframe.core.conductor import GlobalFixCoordinator
|
|
24
|
+
from codeframe.core.streaming import EventPublisher
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _utc_now() -> datetime:
|
|
28
|
+
"""Get current UTC time as timezone-aware datetime."""
|
|
29
|
+
return datetime.now(timezone.utc)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RunStatus(str, Enum):
|
|
33
|
+
"""Status of a task execution run."""
|
|
34
|
+
|
|
35
|
+
RUNNING = "RUNNING"
|
|
36
|
+
COMPLETED = "COMPLETED"
|
|
37
|
+
FAILED = "FAILED"
|
|
38
|
+
BLOCKED = "BLOCKED"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Run:
|
|
43
|
+
"""Represents a task execution run.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
id: Unique run identifier (UUID)
|
|
47
|
+
workspace_id: Workspace this run belongs to
|
|
48
|
+
task_id: Task being executed
|
|
49
|
+
status: Current run status
|
|
50
|
+
started_at: When the run started
|
|
51
|
+
completed_at: When the run finished (if finished)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
workspace_id: str
|
|
56
|
+
task_id: str
|
|
57
|
+
status: RunStatus
|
|
58
|
+
started_at: datetime
|
|
59
|
+
completed_at: Optional[datetime]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def start_task_run(workspace: Workspace, task_id: str) -> Run:
|
|
63
|
+
"""Start a new run for a task.
|
|
64
|
+
|
|
65
|
+
Transitions the task to IN_PROGRESS and creates a run record.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
workspace: Target workspace
|
|
69
|
+
task_id: Task to run
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Created Run
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If task not found
|
|
76
|
+
InvalidTransitionError: If task can't transition to IN_PROGRESS
|
|
77
|
+
"""
|
|
78
|
+
# Get the task
|
|
79
|
+
task = tasks.get(workspace, task_id)
|
|
80
|
+
if not task:
|
|
81
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
82
|
+
|
|
83
|
+
# Check if there's already an active run
|
|
84
|
+
active = get_active_run(workspace, task_id)
|
|
85
|
+
if active:
|
|
86
|
+
raise ValueError(f"Task already has an active run: {active.id}")
|
|
87
|
+
|
|
88
|
+
# Transition task to IN_PROGRESS (validates the transition)
|
|
89
|
+
# If task is in BACKLOG, we need to go through READY first
|
|
90
|
+
if task.status == TaskStatus.BACKLOG:
|
|
91
|
+
tasks.update_status(workspace, task_id, TaskStatus.READY)
|
|
92
|
+
|
|
93
|
+
if task.status != TaskStatus.IN_PROGRESS:
|
|
94
|
+
tasks.update_status(workspace, task_id, TaskStatus.IN_PROGRESS)
|
|
95
|
+
|
|
96
|
+
# Create run record
|
|
97
|
+
run_id = str(uuid.uuid4())
|
|
98
|
+
now = _utc_now().isoformat()
|
|
99
|
+
|
|
100
|
+
conn = get_db_connection(workspace)
|
|
101
|
+
try:
|
|
102
|
+
cursor = conn.cursor()
|
|
103
|
+
cursor.execute(
|
|
104
|
+
"""
|
|
105
|
+
INSERT INTO runs (id, workspace_id, task_id, status, started_at)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?)
|
|
107
|
+
""",
|
|
108
|
+
(run_id, workspace.id, task_id, RunStatus.RUNNING.value, now),
|
|
109
|
+
)
|
|
110
|
+
conn.commit()
|
|
111
|
+
finally:
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
run = Run(
|
|
115
|
+
id=run_id,
|
|
116
|
+
workspace_id=workspace.id,
|
|
117
|
+
task_id=task_id,
|
|
118
|
+
status=RunStatus.RUNNING,
|
|
119
|
+
started_at=datetime.fromisoformat(now),
|
|
120
|
+
completed_at=None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Emit run started event
|
|
124
|
+
events.emit_for_workspace(
|
|
125
|
+
workspace,
|
|
126
|
+
events.EventType.RUN_STARTED,
|
|
127
|
+
{"run_id": run_id, "task_id": task_id},
|
|
128
|
+
print_event=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return run
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_run(workspace: Workspace, run_id: str) -> Optional[Run]:
|
|
135
|
+
"""Get a run by ID.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
workspace: Workspace to query
|
|
139
|
+
run_id: Run identifier
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Run if found, None otherwise
|
|
143
|
+
"""
|
|
144
|
+
conn = get_db_connection(workspace)
|
|
145
|
+
cursor = conn.cursor()
|
|
146
|
+
|
|
147
|
+
cursor.execute(
|
|
148
|
+
"""
|
|
149
|
+
SELECT id, workspace_id, task_id, status, started_at, completed_at
|
|
150
|
+
FROM runs
|
|
151
|
+
WHERE workspace_id = ? AND id = ?
|
|
152
|
+
""",
|
|
153
|
+
(workspace.id, run_id),
|
|
154
|
+
)
|
|
155
|
+
row = cursor.fetchone()
|
|
156
|
+
conn.close()
|
|
157
|
+
|
|
158
|
+
if not row:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
return _row_to_run(row)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_active_run(workspace: Workspace, task_id: str) -> Optional[Run]:
|
|
165
|
+
"""Get the active (RUNNING or BLOCKED) run for a task.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
workspace: Workspace to query
|
|
169
|
+
task_id: Task identifier
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Run if found, None otherwise
|
|
173
|
+
"""
|
|
174
|
+
conn = get_db_connection(workspace)
|
|
175
|
+
cursor = conn.cursor()
|
|
176
|
+
|
|
177
|
+
cursor.execute(
|
|
178
|
+
"""
|
|
179
|
+
SELECT id, workspace_id, task_id, status, started_at, completed_at
|
|
180
|
+
FROM runs
|
|
181
|
+
WHERE workspace_id = ? AND task_id = ? AND status IN ('RUNNING', 'BLOCKED')
|
|
182
|
+
ORDER BY started_at DESC
|
|
183
|
+
LIMIT 1
|
|
184
|
+
""",
|
|
185
|
+
(workspace.id, task_id),
|
|
186
|
+
)
|
|
187
|
+
row = cursor.fetchone()
|
|
188
|
+
conn.close()
|
|
189
|
+
|
|
190
|
+
if not row:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
return _row_to_run(row)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_latest_run(workspace: Workspace, task_id: str) -> Optional[Run]:
|
|
197
|
+
"""Get the most recent run for a task (any status).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
workspace: Workspace to query
|
|
201
|
+
task_id: Task identifier
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Run if found, None otherwise
|
|
205
|
+
"""
|
|
206
|
+
conn = get_db_connection(workspace)
|
|
207
|
+
cursor = conn.cursor()
|
|
208
|
+
|
|
209
|
+
cursor.execute(
|
|
210
|
+
"""
|
|
211
|
+
SELECT id, workspace_id, task_id, status, started_at, completed_at
|
|
212
|
+
FROM runs
|
|
213
|
+
WHERE workspace_id = ? AND task_id = ?
|
|
214
|
+
ORDER BY started_at DESC
|
|
215
|
+
LIMIT 1
|
|
216
|
+
""",
|
|
217
|
+
(workspace.id, task_id),
|
|
218
|
+
)
|
|
219
|
+
row = cursor.fetchone()
|
|
220
|
+
conn.close()
|
|
221
|
+
|
|
222
|
+
if not row:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
return _row_to_run(row)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def reset_blocked_run(workspace: Workspace, task_id: str) -> bool:
|
|
229
|
+
"""Reset a blocked run so the task can be re-executed.
|
|
230
|
+
|
|
231
|
+
Marks the blocked run as FAILED and resets the task status to READY.
|
|
232
|
+
This allows the task to be started fresh with a new run.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
workspace: Target workspace
|
|
236
|
+
task_id: Task to reset
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
True if a blocked run was reset, False if no blocked run existed
|
|
240
|
+
"""
|
|
241
|
+
conn = get_db_connection(workspace)
|
|
242
|
+
cursor = conn.cursor()
|
|
243
|
+
|
|
244
|
+
# Find and update blocked run
|
|
245
|
+
cursor.execute(
|
|
246
|
+
"""
|
|
247
|
+
UPDATE runs
|
|
248
|
+
SET status = ?, completed_at = ?
|
|
249
|
+
WHERE workspace_id = ? AND task_id = ? AND status = ?
|
|
250
|
+
""",
|
|
251
|
+
(
|
|
252
|
+
RunStatus.FAILED.value,
|
|
253
|
+
_utc_now().isoformat(),
|
|
254
|
+
workspace.id,
|
|
255
|
+
task_id,
|
|
256
|
+
RunStatus.BLOCKED.value,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
updated = cursor.rowcount > 0
|
|
260
|
+
conn.commit()
|
|
261
|
+
conn.close()
|
|
262
|
+
|
|
263
|
+
if updated:
|
|
264
|
+
# Reset task status to READY
|
|
265
|
+
task = tasks.get(workspace, task_id)
|
|
266
|
+
if task and task.status == TaskStatus.BLOCKED:
|
|
267
|
+
tasks.update_status(workspace, task_id, TaskStatus.READY)
|
|
268
|
+
|
|
269
|
+
return updated
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def list_runs(
|
|
273
|
+
workspace: Workspace,
|
|
274
|
+
task_id: Optional[str] = None,
|
|
275
|
+
status: Optional[RunStatus] = None,
|
|
276
|
+
limit: int = 20,
|
|
277
|
+
) -> list[Run]:
|
|
278
|
+
"""List runs in a workspace.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
workspace: Workspace to query
|
|
282
|
+
task_id: Optional task filter
|
|
283
|
+
status: Optional status filter
|
|
284
|
+
limit: Maximum runs to return
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
List of Runs, newest first
|
|
288
|
+
"""
|
|
289
|
+
conn = get_db_connection(workspace)
|
|
290
|
+
cursor = conn.cursor()
|
|
291
|
+
|
|
292
|
+
query = """
|
|
293
|
+
SELECT id, workspace_id, task_id, status, started_at, completed_at
|
|
294
|
+
FROM runs
|
|
295
|
+
WHERE workspace_id = ?
|
|
296
|
+
"""
|
|
297
|
+
params: list = [workspace.id]
|
|
298
|
+
|
|
299
|
+
if task_id:
|
|
300
|
+
query += " AND task_id = ?"
|
|
301
|
+
params.append(task_id)
|
|
302
|
+
|
|
303
|
+
if status:
|
|
304
|
+
query += " AND status = ?"
|
|
305
|
+
params.append(status.value)
|
|
306
|
+
|
|
307
|
+
query += " ORDER BY started_at DESC LIMIT ?"
|
|
308
|
+
params.append(limit)
|
|
309
|
+
|
|
310
|
+
cursor.execute(query, params)
|
|
311
|
+
rows = cursor.fetchall()
|
|
312
|
+
conn.close()
|
|
313
|
+
|
|
314
|
+
return [_row_to_run(row) for row in rows]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def complete_run(workspace: Workspace, run_id: str) -> Run:
|
|
318
|
+
"""Mark a run as completed.
|
|
319
|
+
|
|
320
|
+
Also transitions the task to DONE.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
workspace: Target workspace
|
|
324
|
+
run_id: Run to complete
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Updated Run
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: If run not found or not in RUNNING status
|
|
331
|
+
"""
|
|
332
|
+
run = get_run(workspace, run_id)
|
|
333
|
+
if not run:
|
|
334
|
+
raise ValueError(f"Run not found: {run_id}")
|
|
335
|
+
|
|
336
|
+
if run.status != RunStatus.RUNNING:
|
|
337
|
+
raise ValueError(f"Run is not running: {run.status}")
|
|
338
|
+
|
|
339
|
+
now = _utc_now().isoformat()
|
|
340
|
+
|
|
341
|
+
conn = get_db_connection(workspace)
|
|
342
|
+
cursor = conn.cursor()
|
|
343
|
+
|
|
344
|
+
cursor.execute(
|
|
345
|
+
"""
|
|
346
|
+
UPDATE runs
|
|
347
|
+
SET status = ?, completed_at = ?
|
|
348
|
+
WHERE id = ?
|
|
349
|
+
""",
|
|
350
|
+
(RunStatus.COMPLETED.value, now, run_id),
|
|
351
|
+
)
|
|
352
|
+
conn.commit()
|
|
353
|
+
conn.close()
|
|
354
|
+
|
|
355
|
+
# Transition task to DONE
|
|
356
|
+
tasks.update_status(workspace, run.task_id, TaskStatus.DONE)
|
|
357
|
+
|
|
358
|
+
# Emit run completed event
|
|
359
|
+
events.emit_for_workspace(
|
|
360
|
+
workspace,
|
|
361
|
+
events.EventType.RUN_COMPLETED,
|
|
362
|
+
{"run_id": run_id, "task_id": run.task_id},
|
|
363
|
+
print_event=True,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
run.status = RunStatus.COMPLETED
|
|
367
|
+
run.completed_at = datetime.fromisoformat(now)
|
|
368
|
+
return run
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def fail_run(workspace: Workspace, run_id: str, reason: str = "") -> Run:
|
|
372
|
+
"""Mark a run as failed.
|
|
373
|
+
|
|
374
|
+
Also transitions the task to FAILED status.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
workspace: Target workspace
|
|
378
|
+
run_id: Run to fail
|
|
379
|
+
reason: Optional failure reason
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Updated Run
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ValueError: If run not found or not in RUNNING status
|
|
386
|
+
"""
|
|
387
|
+
run = get_run(workspace, run_id)
|
|
388
|
+
if not run:
|
|
389
|
+
raise ValueError(f"Run not found: {run_id}")
|
|
390
|
+
|
|
391
|
+
if run.status not in (RunStatus.RUNNING, RunStatus.BLOCKED):
|
|
392
|
+
raise ValueError(f"Run is not active: {run.status}")
|
|
393
|
+
|
|
394
|
+
now = _utc_now().isoformat()
|
|
395
|
+
|
|
396
|
+
conn = get_db_connection(workspace)
|
|
397
|
+
cursor = conn.cursor()
|
|
398
|
+
|
|
399
|
+
cursor.execute(
|
|
400
|
+
"""
|
|
401
|
+
UPDATE runs
|
|
402
|
+
SET status = ?, completed_at = ?
|
|
403
|
+
WHERE id = ?
|
|
404
|
+
""",
|
|
405
|
+
(RunStatus.FAILED.value, now, run_id),
|
|
406
|
+
)
|
|
407
|
+
conn.commit()
|
|
408
|
+
conn.close()
|
|
409
|
+
|
|
410
|
+
# Emit run failed event
|
|
411
|
+
events.emit_for_workspace(
|
|
412
|
+
workspace,
|
|
413
|
+
events.EventType.RUN_FAILED,
|
|
414
|
+
{"run_id": run_id, "task_id": run.task_id, "reason": reason},
|
|
415
|
+
print_event=True,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Update task status to FAILED
|
|
419
|
+
tasks.update_status(workspace, run.task_id, TaskStatus.FAILED)
|
|
420
|
+
|
|
421
|
+
run.status = RunStatus.FAILED
|
|
422
|
+
run.completed_at = datetime.fromisoformat(now)
|
|
423
|
+
return run
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def block_run(workspace: Workspace, run_id: str, blocker_id: str) -> Run:
|
|
427
|
+
"""Mark a run as blocked.
|
|
428
|
+
|
|
429
|
+
Also transitions the task to BLOCKED.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
workspace: Target workspace
|
|
433
|
+
run_id: Run to block
|
|
434
|
+
blocker_id: ID of the blocker that caused the block
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Updated Run
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
ValueError: If run not found or not in RUNNING status
|
|
441
|
+
"""
|
|
442
|
+
run = get_run(workspace, run_id)
|
|
443
|
+
if not run:
|
|
444
|
+
raise ValueError(f"Run not found: {run_id}")
|
|
445
|
+
|
|
446
|
+
if run.status != RunStatus.RUNNING:
|
|
447
|
+
raise ValueError(f"Run is not running: {run.status}")
|
|
448
|
+
|
|
449
|
+
conn = get_db_connection(workspace)
|
|
450
|
+
cursor = conn.cursor()
|
|
451
|
+
|
|
452
|
+
cursor.execute(
|
|
453
|
+
"""
|
|
454
|
+
UPDATE runs
|
|
455
|
+
SET status = ?
|
|
456
|
+
WHERE id = ?
|
|
457
|
+
""",
|
|
458
|
+
(RunStatus.BLOCKED.value, run_id),
|
|
459
|
+
)
|
|
460
|
+
conn.commit()
|
|
461
|
+
conn.close()
|
|
462
|
+
|
|
463
|
+
# Transition task to BLOCKED
|
|
464
|
+
tasks.update_status(workspace, run.task_id, TaskStatus.BLOCKED)
|
|
465
|
+
|
|
466
|
+
run.status = RunStatus.BLOCKED
|
|
467
|
+
return run
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def resume_run(workspace: Workspace, task_id: str) -> Run:
|
|
471
|
+
"""Resume a blocked run.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
workspace: Target workspace
|
|
475
|
+
task_id: Task whose run to resume
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Resumed Run
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
ValueError: If no blocked run found for task
|
|
482
|
+
"""
|
|
483
|
+
run = get_active_run(workspace, task_id)
|
|
484
|
+
if not run:
|
|
485
|
+
raise ValueError(f"No active run found for task: {task_id}")
|
|
486
|
+
|
|
487
|
+
if run.status != RunStatus.BLOCKED:
|
|
488
|
+
raise ValueError(f"Run is not blocked: {run.status}")
|
|
489
|
+
|
|
490
|
+
conn = get_db_connection(workspace)
|
|
491
|
+
cursor = conn.cursor()
|
|
492
|
+
|
|
493
|
+
cursor.execute(
|
|
494
|
+
"""
|
|
495
|
+
UPDATE runs
|
|
496
|
+
SET status = ?
|
|
497
|
+
WHERE id = ?
|
|
498
|
+
""",
|
|
499
|
+
(RunStatus.RUNNING.value, run.id),
|
|
500
|
+
)
|
|
501
|
+
conn.commit()
|
|
502
|
+
conn.close()
|
|
503
|
+
|
|
504
|
+
# Transition task back to IN_PROGRESS
|
|
505
|
+
tasks.update_status(workspace, task_id, TaskStatus.IN_PROGRESS)
|
|
506
|
+
|
|
507
|
+
run.status = RunStatus.RUNNING
|
|
508
|
+
return run
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def stop_run(workspace: Workspace, task_id: str) -> Run:
|
|
512
|
+
"""Stop a running task gracefully.
|
|
513
|
+
|
|
514
|
+
Marks the run as failed and transitions task back to READY.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
workspace: Target workspace
|
|
518
|
+
task_id: Task whose run to stop
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Stopped Run
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
ValueError: If no active run found for task
|
|
525
|
+
"""
|
|
526
|
+
run = get_active_run(workspace, task_id)
|
|
527
|
+
if not run:
|
|
528
|
+
raise ValueError(f"No active run found for task: {task_id}")
|
|
529
|
+
|
|
530
|
+
now = _utc_now().isoformat()
|
|
531
|
+
|
|
532
|
+
conn = get_db_connection(workspace)
|
|
533
|
+
cursor = conn.cursor()
|
|
534
|
+
|
|
535
|
+
cursor.execute(
|
|
536
|
+
"""
|
|
537
|
+
UPDATE runs
|
|
538
|
+
SET status = ?, completed_at = ?
|
|
539
|
+
WHERE id = ?
|
|
540
|
+
""",
|
|
541
|
+
(RunStatus.FAILED.value, now, run.id),
|
|
542
|
+
)
|
|
543
|
+
conn.commit()
|
|
544
|
+
conn.close()
|
|
545
|
+
|
|
546
|
+
# Transition task back to READY so it can be restarted (if not already)
|
|
547
|
+
task = tasks.get(workspace, task_id)
|
|
548
|
+
if task and task.status != TaskStatus.READY:
|
|
549
|
+
tasks.update_status(workspace, task_id, TaskStatus.READY)
|
|
550
|
+
|
|
551
|
+
# Emit event
|
|
552
|
+
events.emit_for_workspace(
|
|
553
|
+
workspace,
|
|
554
|
+
events.EventType.RUN_FAILED,
|
|
555
|
+
{"run_id": run.id, "task_id": task_id, "reason": "Stopped by user"},
|
|
556
|
+
print_event=True,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
run.status = RunStatus.FAILED
|
|
560
|
+
run.completed_at = datetime.fromisoformat(now)
|
|
561
|
+
return run
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def execute_stub(workspace: Workspace, run: Run) -> None:
|
|
565
|
+
"""Stub agent execution loop (deprecated).
|
|
566
|
+
|
|
567
|
+
This is a placeholder kept for backwards compatibility.
|
|
568
|
+
Use execute_agent() for real agent execution.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
workspace: Target workspace
|
|
572
|
+
run: Run to execute
|
|
573
|
+
"""
|
|
574
|
+
# Emit agent step started
|
|
575
|
+
events.emit_for_workspace(
|
|
576
|
+
workspace,
|
|
577
|
+
events.EventType.AGENT_STEP_STARTED,
|
|
578
|
+
{"run_id": run.id, "step": 1, "description": "Analyzing task"},
|
|
579
|
+
print_event=True,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Emit agent step completed (stub does nothing real)
|
|
583
|
+
events.emit_for_workspace(
|
|
584
|
+
workspace,
|
|
585
|
+
events.EventType.AGENT_STEP_COMPLETED,
|
|
586
|
+
{"run_id": run.id, "step": 1, "description": "Analysis complete (stub)"},
|
|
587
|
+
print_event=True,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def execute_agent(
|
|
592
|
+
workspace: Workspace,
|
|
593
|
+
run: Run,
|
|
594
|
+
dry_run: bool = False,
|
|
595
|
+
debug: bool = False,
|
|
596
|
+
verbose: bool = False,
|
|
597
|
+
fix_coordinator: Optional["GlobalFixCoordinator"] = None,
|
|
598
|
+
event_publisher: Optional["EventPublisher"] = None,
|
|
599
|
+
engine: str = "react",
|
|
600
|
+
stall_timeout_s: int = 300,
|
|
601
|
+
stall_action: str = "blocker",
|
|
602
|
+
isolation: str = "none",
|
|
603
|
+
cloud_timeout_minutes: int = 30,
|
|
604
|
+
llm_provider: Optional[str] = None,
|
|
605
|
+
llm_model: Optional[str] = None,
|
|
606
|
+
) -> "AgentState":
|
|
607
|
+
"""Execute a task using the agent orchestrator.
|
|
608
|
+
|
|
609
|
+
This is the main entry point for real agent execution.
|
|
610
|
+
It coordinates context loading, planning, execution, and verification.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
workspace: Target workspace
|
|
614
|
+
run: Run to execute
|
|
615
|
+
dry_run: If True, don't make actual changes
|
|
616
|
+
debug: If True, write detailed debug log to workspace
|
|
617
|
+
verbose: If True, print detailed progress to stdout
|
|
618
|
+
fix_coordinator: Optional coordinator for global fixes (for parallel execution)
|
|
619
|
+
event_publisher: Optional EventPublisher for SSE streaming (real-time events)
|
|
620
|
+
engine: Agent engine to use ("react", "plan", "claude-code", "opencode", "cloud", "built-in")
|
|
621
|
+
stall_timeout_s: Seconds without tool activity before stall detection (0 = disabled)
|
|
622
|
+
stall_action: Recovery action on stall ("blocker", "retry", or "fail")
|
|
623
|
+
cloud_timeout_minutes: Sandbox timeout for cloud engine (1-60 minutes, default: 30)
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
Final AgentState after execution
|
|
627
|
+
|
|
628
|
+
Raises:
|
|
629
|
+
ValueError: If ANTHROPIC_API_KEY is not set (for builtin engines) or engine is invalid
|
|
630
|
+
"""
|
|
631
|
+
import os
|
|
632
|
+
from codeframe.core.agent import AgentState, AgentStatus
|
|
633
|
+
from codeframe.adapters.llm import get_provider
|
|
634
|
+
from codeframe.core.diagnostics import RunLogger, LogCategory
|
|
635
|
+
from codeframe.core.engine_registry import (
|
|
636
|
+
is_external_engine, resolve_engine, get_external_adapter, get_builtin_adapter,
|
|
637
|
+
)
|
|
638
|
+
from codeframe.core.adapters.agent_adapter import AgentEvent as AdapterEvent
|
|
639
|
+
|
|
640
|
+
# Resolve engine (handles "built-in" alias and CODEFRAME_ENGINE env var)
|
|
641
|
+
engine = resolve_engine(engine)
|
|
642
|
+
|
|
643
|
+
# Resolve LLM provider: CLI flag → env var → workspace config → default "anthropic"
|
|
644
|
+
from codeframe.core.config import load_environment_config as _load_cfg
|
|
645
|
+
_env_cfg = _load_cfg(workspace.repo_path)
|
|
646
|
+
_cfg_provider = _env_cfg.llm.provider if (_env_cfg and _env_cfg.llm) else None
|
|
647
|
+
_cfg_model = _env_cfg.llm.model if (_env_cfg and _env_cfg.llm) else None
|
|
648
|
+
_cfg_base_url = _env_cfg.llm.base_url if (_env_cfg and _env_cfg.llm) else None
|
|
649
|
+
|
|
650
|
+
provider_type = (
|
|
651
|
+
llm_provider
|
|
652
|
+
or os.getenv("CODEFRAME_LLM_PROVIDER")
|
|
653
|
+
or _cfg_provider
|
|
654
|
+
or "anthropic"
|
|
655
|
+
)
|
|
656
|
+
model_override = (
|
|
657
|
+
llm_model
|
|
658
|
+
or os.getenv("CODEFRAME_LLM_MODEL")
|
|
659
|
+
or _cfg_model
|
|
660
|
+
)
|
|
661
|
+
base_url_override = _cfg_base_url or os.getenv("OPENAI_BASE_URL")
|
|
662
|
+
|
|
663
|
+
# External engines manage their own authentication
|
|
664
|
+
if not is_external_engine(engine):
|
|
665
|
+
if provider_type == "anthropic" and not os.getenv("ANTHROPIC_API_KEY"):
|
|
666
|
+
raise ValueError(
|
|
667
|
+
"ANTHROPIC_API_KEY environment variable is required for agent execution. "
|
|
668
|
+
"Set it with: export ANTHROPIC_API_KEY=your-key"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Only create LLM provider for builtin engines (external engines manage their own)
|
|
672
|
+
if not is_external_engine(engine):
|
|
673
|
+
provider_kwargs = {}
|
|
674
|
+
if model_override:
|
|
675
|
+
provider_kwargs["model"] = model_override
|
|
676
|
+
if base_url_override:
|
|
677
|
+
provider_kwargs["base_url"] = base_url_override
|
|
678
|
+
provider = get_provider(provider_type, **provider_kwargs)
|
|
679
|
+
else:
|
|
680
|
+
provider = None
|
|
681
|
+
|
|
682
|
+
# Create run logger for structured logging
|
|
683
|
+
run_logger = RunLogger(workspace, run.id, run.task_id)
|
|
684
|
+
run_logger.info(LogCategory.AGENT_ACTION, "Starting agent execution", {
|
|
685
|
+
"task_id": run.task_id,
|
|
686
|
+
"dry_run": dry_run,
|
|
687
|
+
"verbose": verbose,
|
|
688
|
+
"engine": engine,
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
# Create output logger for streaming (cf work follow)
|
|
692
|
+
from codeframe.core.streaming import RunOutputLogger
|
|
693
|
+
output_logger = RunOutputLogger(workspace, run.id)
|
|
694
|
+
|
|
695
|
+
# Load hook config (before main try block so it's available everywhere)
|
|
696
|
+
from codeframe.core.config import load_environment_config
|
|
697
|
+
from codeframe.core.hooks import HookAbortError, HookContext, execute_hook
|
|
698
|
+
env_config = load_environment_config(workspace.repo_path)
|
|
699
|
+
hook_ctx: HookContext | None = None
|
|
700
|
+
if env_config:
|
|
701
|
+
task_record = tasks.get(workspace, run.task_id)
|
|
702
|
+
hook_ctx = HookContext(
|
|
703
|
+
task_id=run.task_id,
|
|
704
|
+
task_title=task_record.title if task_record else "",
|
|
705
|
+
task_status="in_progress",
|
|
706
|
+
workspace_path=str(workspace.repo_path),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
import time as _time_mod
|
|
710
|
+
_perf_start_ms = int(_time_mod.monotonic() * 1000)
|
|
711
|
+
|
|
712
|
+
# Create execution context (handles isolation; NONE is a no-op)
|
|
713
|
+
from codeframe.core.sandbox.context import IsolationLevel, create_execution_context
|
|
714
|
+
exec_ctx = create_execution_context(
|
|
715
|
+
run.task_id, IsolationLevel(isolation), workspace.repo_path
|
|
716
|
+
)
|
|
717
|
+
effective_repo_path = exec_ctx.workspace_path
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
# Execute before_task hook (aborts on failure)
|
|
721
|
+
if env_config and hook_ctx:
|
|
722
|
+
try:
|
|
723
|
+
hook_result = execute_hook(
|
|
724
|
+
"before_task", env_config, workspace.repo_path, hook_ctx,
|
|
725
|
+
abort_on_failure=True,
|
|
726
|
+
)
|
|
727
|
+
if hook_result:
|
|
728
|
+
events.emit_for_workspace(
|
|
729
|
+
workspace, events.EventType.HOOK_EXECUTED,
|
|
730
|
+
{"hook": "before_task", "success": hook_result.success},
|
|
731
|
+
)
|
|
732
|
+
except HookAbortError as hook_err:
|
|
733
|
+
events.emit_for_workspace(
|
|
734
|
+
workspace, events.EventType.HOOK_FAILED,
|
|
735
|
+
{"hook": "before_task", "error": str(hook_err)},
|
|
736
|
+
)
|
|
737
|
+
fail_run(workspace, run.id)
|
|
738
|
+
from codeframe.core.agent import AgentState, AgentStatus
|
|
739
|
+
return AgentState(status=AgentStatus.FAILED)
|
|
740
|
+
# Create event callback to emit workspace events and log
|
|
741
|
+
def on_agent_event(event_type: str, data: dict) -> None:
|
|
742
|
+
events.emit_for_workspace(
|
|
743
|
+
workspace,
|
|
744
|
+
events.EventType.AGENT_STEP_STARTED if "started" in event_type else events.EventType.AGENT_STEP_COMPLETED,
|
|
745
|
+
{"run_id": run.id, "agent_event": event_type, **data},
|
|
746
|
+
print_event=True,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Also log to run logger for diagnosis
|
|
750
|
+
category = _event_type_to_category(event_type)
|
|
751
|
+
run_logger.info(category, f"Agent event: {event_type}", data)
|
|
752
|
+
|
|
753
|
+
# Bridge AgentEvent callbacks to workspace event system
|
|
754
|
+
def on_adapter_event(event: AdapterEvent) -> None:
|
|
755
|
+
on_agent_event(event.type, event.data)
|
|
756
|
+
|
|
757
|
+
# Get adapter via registry and run
|
|
758
|
+
if is_external_engine(engine):
|
|
759
|
+
from codeframe.core.context_packager import TaskContextPackager
|
|
760
|
+
from codeframe.core.adapters.verification_wrapper import VerificationWrapper
|
|
761
|
+
|
|
762
|
+
run_logger.info(
|
|
763
|
+
LogCategory.AGENT_ACTION,
|
|
764
|
+
f"Using external engine: {engine}",
|
|
765
|
+
{"engine": engine},
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
packager = TaskContextPackager(workspace)
|
|
769
|
+
packaged = packager.build(run.task_id)
|
|
770
|
+
|
|
771
|
+
adapter_kwargs = {}
|
|
772
|
+
if engine == "cloud":
|
|
773
|
+
adapter_kwargs["timeout_minutes"] = cloud_timeout_minutes
|
|
774
|
+
adapter = get_external_adapter(engine, **adapter_kwargs)
|
|
775
|
+
wrapper = VerificationWrapper(
|
|
776
|
+
adapter, workspace, max_correction_rounds=5, verbose=verbose,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
result = wrapper.run(
|
|
780
|
+
run.task_id, packaged.prompt, effective_repo_path,
|
|
781
|
+
on_event=on_adapter_event,
|
|
782
|
+
)
|
|
783
|
+
else:
|
|
784
|
+
from codeframe.core.stall_detector import StallAction
|
|
785
|
+
|
|
786
|
+
resolved_action = StallAction(stall_action)
|
|
787
|
+
builtin_kwargs: dict = {
|
|
788
|
+
"event_publisher": event_publisher,
|
|
789
|
+
"dry_run": dry_run,
|
|
790
|
+
"verbose": verbose,
|
|
791
|
+
"debug": debug,
|
|
792
|
+
"output_logger": output_logger,
|
|
793
|
+
"fix_coordinator": fix_coordinator,
|
|
794
|
+
}
|
|
795
|
+
# Stall detection is only relevant for the react engine
|
|
796
|
+
if engine in ("react", "built-in"):
|
|
797
|
+
builtin_kwargs["stall_timeout_s"] = stall_timeout_s
|
|
798
|
+
builtin_kwargs["stall_action"] = resolved_action
|
|
799
|
+
|
|
800
|
+
adapter = get_builtin_adapter(
|
|
801
|
+
engine, workspace, provider, **builtin_kwargs,
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
result = adapter.run(
|
|
805
|
+
run.task_id, "", effective_repo_path,
|
|
806
|
+
on_event=on_adapter_event,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
run_logger.info(
|
|
810
|
+
LogCategory.AGENT_ACTION,
|
|
811
|
+
f"Engine '{engine}' completed: {result.status}",
|
|
812
|
+
{"engine": engine, "output_length": len(result.output)},
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# Map AgentResult to AgentState for rest of runtime
|
|
816
|
+
status_map = {
|
|
817
|
+
"completed": AgentStatus.COMPLETED,
|
|
818
|
+
"failed": AgentStatus.FAILED,
|
|
819
|
+
"blocked": AgentStatus.BLOCKED,
|
|
820
|
+
}
|
|
821
|
+
agent_status = status_map.get(result.status, AgentStatus.FAILED)
|
|
822
|
+
state = AgentState(status=agent_status)
|
|
823
|
+
|
|
824
|
+
# Create blocker if adapter reported one and populate state for CLI
|
|
825
|
+
if result.status == "blocked" and result.blocker_question:
|
|
826
|
+
from codeframe.core import blockers as blockers_mod
|
|
827
|
+
blocker_obj = blockers_mod.create(
|
|
828
|
+
workspace, task_id=run.task_id, question=result.blocker_question,
|
|
829
|
+
)
|
|
830
|
+
state.blocker = blocker_obj
|
|
831
|
+
|
|
832
|
+
# Log final status
|
|
833
|
+
if state.status == AgentStatus.COMPLETED:
|
|
834
|
+
run_logger.info(LogCategory.STATE_CHANGE, "Agent completed successfully")
|
|
835
|
+
elif state.status == AgentStatus.BLOCKED:
|
|
836
|
+
run_logger.warning(LogCategory.BLOCKER, f"Agent blocked: {result.blocker_question or 'Unknown'}", {
|
|
837
|
+
"blocker_question": result.blocker_question or "Unknown",
|
|
838
|
+
})
|
|
839
|
+
elif state.status == AgentStatus.FAILED:
|
|
840
|
+
run_logger.error(LogCategory.ERROR, "Agent execution failed", {
|
|
841
|
+
"error": (result.error or "")[:500],
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
# Update run status based on agent result (before hooks, so hooks see final state)
|
|
845
|
+
if state.status == AgentStatus.COMPLETED:
|
|
846
|
+
complete_run(workspace, run.id)
|
|
847
|
+
elif state.status == AgentStatus.BLOCKED:
|
|
848
|
+
block_run(workspace, run.id, "")
|
|
849
|
+
elif state.status == AgentStatus.FAILED:
|
|
850
|
+
fail_run(workspace, run.id)
|
|
851
|
+
|
|
852
|
+
# Record engine performance metrics
|
|
853
|
+
try:
|
|
854
|
+
_perf_duration_ms = int(_time_mod.monotonic() * 1000) - _perf_start_ms
|
|
855
|
+
_perf_tokens = 0
|
|
856
|
+
if hasattr(result, 'token_usage') and result.token_usage:
|
|
857
|
+
_perf_tokens = result.token_usage.total_tokens
|
|
858
|
+
|
|
859
|
+
engine_stats.record_run(
|
|
860
|
+
workspace=workspace,
|
|
861
|
+
run_id=run.id,
|
|
862
|
+
engine=engine,
|
|
863
|
+
task_id=run.task_id,
|
|
864
|
+
status=agent_status.value.upper(),
|
|
865
|
+
duration_ms=_perf_duration_ms,
|
|
866
|
+
tokens_used=_perf_tokens,
|
|
867
|
+
gates_passed=None,
|
|
868
|
+
self_corrections=0,
|
|
869
|
+
)
|
|
870
|
+
except Exception:
|
|
871
|
+
logger.warning("Engine stats recording failed", exc_info=True)
|
|
872
|
+
|
|
873
|
+
# Persist cloud execution metadata if the adapter returned it
|
|
874
|
+
if engine == "cloud" and hasattr(result, "cloud_metadata") and result.cloud_metadata:
|
|
875
|
+
try:
|
|
876
|
+
from codeframe.adapters.e2b.budget import record_cloud_run
|
|
877
|
+
meta = result.cloud_metadata
|
|
878
|
+
record_cloud_run(
|
|
879
|
+
workspace=workspace,
|
|
880
|
+
run_id=run.id,
|
|
881
|
+
sandbox_minutes=meta.get("sandbox_minutes", 0.0),
|
|
882
|
+
cost_usd=meta.get("cost_usd_estimate", 0.0),
|
|
883
|
+
files_uploaded=meta.get("files_uploaded", 0),
|
|
884
|
+
files_downloaded=meta.get("files_downloaded", 0),
|
|
885
|
+
scan_blocked=meta.get("credential_scan_blocked", 0),
|
|
886
|
+
)
|
|
887
|
+
except Exception:
|
|
888
|
+
logger.warning("Cloud run metadata recording failed", exc_info=True)
|
|
889
|
+
|
|
890
|
+
# Execute after_task hooks (non-blocking, after state is persisted)
|
|
891
|
+
if env_config and hook_ctx:
|
|
892
|
+
after_hook = None
|
|
893
|
+
if state.status == AgentStatus.COMPLETED:
|
|
894
|
+
hook_ctx.task_status = "done"
|
|
895
|
+
after_hook = "after_task_success"
|
|
896
|
+
elif state.status == AgentStatus.FAILED:
|
|
897
|
+
hook_ctx.task_status = "failed"
|
|
898
|
+
after_hook = "after_task_failure"
|
|
899
|
+
|
|
900
|
+
if after_hook:
|
|
901
|
+
hook_result = execute_hook(
|
|
902
|
+
after_hook, env_config, workspace.repo_path, hook_ctx,
|
|
903
|
+
abort_on_failure=False,
|
|
904
|
+
)
|
|
905
|
+
if hook_result:
|
|
906
|
+
evt = events.EventType.HOOK_EXECUTED if hook_result.success else events.EventType.HOOK_FAILED
|
|
907
|
+
events.emit_for_workspace(workspace, evt, {"hook": after_hook, "success": hook_result.success})
|
|
908
|
+
|
|
909
|
+
return state
|
|
910
|
+
|
|
911
|
+
except Exception as exc:
|
|
912
|
+
# Fail the run so it doesn't stay IN_PROGRESS forever
|
|
913
|
+
run_logger.error(LogCategory.ERROR, f"Unhandled error in execute_agent: {exc}", {})
|
|
914
|
+
try:
|
|
915
|
+
fail_run(workspace, run.id)
|
|
916
|
+
except Exception:
|
|
917
|
+
pass # Best-effort — don't mask the original error
|
|
918
|
+
# Fire after_task_failure hook even on unhandled exceptions
|
|
919
|
+
if env_config and hook_ctx:
|
|
920
|
+
hook_ctx.task_status = "failed"
|
|
921
|
+
execute_hook(
|
|
922
|
+
"after_task_failure", env_config, workspace.repo_path, hook_ctx,
|
|
923
|
+
abort_on_failure=False,
|
|
924
|
+
)
|
|
925
|
+
return AgentState(status=AgentStatus.FAILED)
|
|
926
|
+
|
|
927
|
+
finally:
|
|
928
|
+
# Always close the output logger to ensure file is properly flushed
|
|
929
|
+
output_logger.close()
|
|
930
|
+
# Clean up execution context (no-op for NONE, removes worktree for WORKTREE)
|
|
931
|
+
exec_ctx.cleanup()
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _event_type_to_category(event_type: str):
|
|
935
|
+
"""Map agent event types to log categories.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
event_type: The agent event type string
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
LogCategory appropriate for the event
|
|
942
|
+
"""
|
|
943
|
+
from codeframe.core.diagnostics import LogCategory
|
|
944
|
+
|
|
945
|
+
if "planning" in event_type.lower():
|
|
946
|
+
return LogCategory.AGENT_ACTION
|
|
947
|
+
elif "verification" in event_type.lower() or "gate" in event_type.lower():
|
|
948
|
+
return LogCategory.VERIFICATION
|
|
949
|
+
elif "error" in event_type.lower() or "failed" in event_type.lower():
|
|
950
|
+
return LogCategory.ERROR
|
|
951
|
+
elif "blocker" in event_type.lower():
|
|
952
|
+
return LogCategory.BLOCKER
|
|
953
|
+
elif "llm" in event_type.lower() or "context" in event_type.lower():
|
|
954
|
+
return LogCategory.LLM_CALL
|
|
955
|
+
elif "file" in event_type.lower():
|
|
956
|
+
return LogCategory.FILE_OPERATION
|
|
957
|
+
elif "shell" in event_type.lower() or "command" in event_type.lower():
|
|
958
|
+
return LogCategory.SHELL_COMMAND
|
|
959
|
+
else:
|
|
960
|
+
return LogCategory.AGENT_ACTION
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _row_to_run(row: tuple) -> Run:
|
|
964
|
+
"""Convert a database row to a Run object."""
|
|
965
|
+
return Run(
|
|
966
|
+
id=row[0],
|
|
967
|
+
workspace_id=row[1],
|
|
968
|
+
task_id=row[2],
|
|
969
|
+
status=RunStatus(row[3]),
|
|
970
|
+
started_at=datetime.fromisoformat(row[4]),
|
|
971
|
+
completed_at=datetime.fromisoformat(row[5]) if row[5] else None,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ============================================================================
|
|
976
|
+
# Task Approval and Assignment (Route Delegation Helpers)
|
|
977
|
+
# ============================================================================
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@dataclass
|
|
981
|
+
class ApprovalResult:
|
|
982
|
+
"""Result of task approval operation.
|
|
983
|
+
|
|
984
|
+
Attributes:
|
|
985
|
+
approved_count: Number of tasks approved (transitioned to READY)
|
|
986
|
+
excluded_count: Number of tasks excluded from approval
|
|
987
|
+
approved_task_ids: List of approved task IDs
|
|
988
|
+
excluded_task_ids: List of excluded task IDs
|
|
989
|
+
"""
|
|
990
|
+
|
|
991
|
+
approved_count: int
|
|
992
|
+
excluded_count: int
|
|
993
|
+
approved_task_ids: list[str]
|
|
994
|
+
excluded_task_ids: list[str]
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def approve_tasks(
|
|
998
|
+
workspace: Workspace,
|
|
999
|
+
excluded_task_ids: Optional[list[str]] = None,
|
|
1000
|
+
) -> ApprovalResult:
|
|
1001
|
+
"""Approve tasks for execution by transitioning them to READY status.
|
|
1002
|
+
|
|
1003
|
+
This function handles the "task approval" workflow:
|
|
1004
|
+
1. Gets all BACKLOG tasks in the workspace
|
|
1005
|
+
2. Excludes specified tasks (if any)
|
|
1006
|
+
3. Transitions remaining tasks to READY status
|
|
1007
|
+
|
|
1008
|
+
This is the v2 equivalent of the v1 approval endpoint. It does NOT
|
|
1009
|
+
trigger execution - use start_approved_batch() for that.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
workspace: Target workspace
|
|
1013
|
+
excluded_task_ids: Optional list of task IDs to exclude from approval
|
|
1014
|
+
|
|
1015
|
+
Returns:
|
|
1016
|
+
ApprovalResult with counts and IDs
|
|
1017
|
+
|
|
1018
|
+
Example:
|
|
1019
|
+
result = approve_tasks(workspace, excluded_task_ids=["task-1", "task-2"])
|
|
1020
|
+
print(f"Approved {result.approved_count} tasks")
|
|
1021
|
+
if result.approved_count > 0:
|
|
1022
|
+
batch = start_approved_batch(workspace, result.approved_task_ids)
|
|
1023
|
+
"""
|
|
1024
|
+
excluded = set(excluded_task_ids or [])
|
|
1025
|
+
|
|
1026
|
+
# Get all BACKLOG tasks
|
|
1027
|
+
backlog_tasks = tasks.list_tasks(workspace, status=TaskStatus.BACKLOG)
|
|
1028
|
+
|
|
1029
|
+
approved_ids = []
|
|
1030
|
+
excluded_ids = []
|
|
1031
|
+
|
|
1032
|
+
for task in backlog_tasks:
|
|
1033
|
+
if task.id in excluded:
|
|
1034
|
+
excluded_ids.append(task.id)
|
|
1035
|
+
else:
|
|
1036
|
+
# Transition to READY
|
|
1037
|
+
tasks.update_status(workspace, task.id, TaskStatus.READY)
|
|
1038
|
+
approved_ids.append(task.id)
|
|
1039
|
+
|
|
1040
|
+
logger.info(
|
|
1041
|
+
f"Approved {len(approved_ids)} tasks, excluded {len(excluded_ids)} "
|
|
1042
|
+
f"for workspace {workspace.id}"
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
return ApprovalResult(
|
|
1046
|
+
approved_count=len(approved_ids),
|
|
1047
|
+
excluded_count=len(excluded_ids),
|
|
1048
|
+
approved_task_ids=approved_ids,
|
|
1049
|
+
excluded_task_ids=excluded_ids,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@dataclass
|
|
1054
|
+
class AssignmentResult:
|
|
1055
|
+
"""Result of task assignment check.
|
|
1056
|
+
|
|
1057
|
+
Attributes:
|
|
1058
|
+
pending_count: Number of pending (READY) tasks
|
|
1059
|
+
executing_count: Number of tasks currently executing (IN_PROGRESS)
|
|
1060
|
+
can_assign: Whether new tasks can be assigned
|
|
1061
|
+
reason: Explanation if can_assign is False
|
|
1062
|
+
"""
|
|
1063
|
+
|
|
1064
|
+
pending_count: int
|
|
1065
|
+
executing_count: int
|
|
1066
|
+
can_assign: bool
|
|
1067
|
+
reason: str
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def check_assignment_status(workspace: Workspace) -> AssignmentResult:
|
|
1071
|
+
"""Check if tasks can be assigned for execution.
|
|
1072
|
+
|
|
1073
|
+
This function helps determine whether to start new task execution:
|
|
1074
|
+
1. Counts pending (READY) tasks
|
|
1075
|
+
2. Counts currently executing (IN_PROGRESS) tasks
|
|
1076
|
+
3. Determines if new assignments are possible
|
|
1077
|
+
|
|
1078
|
+
Used by routes to provide feedback before triggering execution.
|
|
1079
|
+
|
|
1080
|
+
Args:
|
|
1081
|
+
workspace: Target workspace
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
AssignmentResult with status and explanation
|
|
1085
|
+
|
|
1086
|
+
Example:
|
|
1087
|
+
status = check_assignment_status(workspace)
|
|
1088
|
+
if status.can_assign:
|
|
1089
|
+
batch = start_approved_batch(workspace)
|
|
1090
|
+
else:
|
|
1091
|
+
print(status.reason)
|
|
1092
|
+
"""
|
|
1093
|
+
# Count tasks by status
|
|
1094
|
+
status_counts = tasks.count_by_status(workspace)
|
|
1095
|
+
ready_count = status_counts.get(TaskStatus.READY.value, 0)
|
|
1096
|
+
in_progress_count = status_counts.get(TaskStatus.IN_PROGRESS.value, 0)
|
|
1097
|
+
|
|
1098
|
+
if ready_count == 0:
|
|
1099
|
+
return AssignmentResult(
|
|
1100
|
+
pending_count=0,
|
|
1101
|
+
executing_count=in_progress_count,
|
|
1102
|
+
can_assign=False,
|
|
1103
|
+
reason="No pending tasks to assign.",
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
if in_progress_count > 0:
|
|
1107
|
+
return AssignmentResult(
|
|
1108
|
+
pending_count=ready_count,
|
|
1109
|
+
executing_count=in_progress_count,
|
|
1110
|
+
can_assign=False,
|
|
1111
|
+
reason=f"Execution already in progress ({in_progress_count} task(s) running).",
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
return AssignmentResult(
|
|
1115
|
+
pending_count=ready_count,
|
|
1116
|
+
executing_count=0,
|
|
1117
|
+
can_assign=True,
|
|
1118
|
+
reason=f"{ready_count} task(s) ready for assignment.",
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def get_ready_task_ids(workspace: Workspace) -> list[str]:
|
|
1123
|
+
"""Get IDs of all READY tasks in the workspace.
|
|
1124
|
+
|
|
1125
|
+
Convenience function for starting batch execution.
|
|
1126
|
+
|
|
1127
|
+
Args:
|
|
1128
|
+
workspace: Target workspace
|
|
1129
|
+
|
|
1130
|
+
Returns:
|
|
1131
|
+
List of task IDs in READY status
|
|
1132
|
+
"""
|
|
1133
|
+
ready_tasks = tasks.list_tasks(workspace, status=TaskStatus.READY)
|
|
1134
|
+
return [t.id for t in ready_tasks]
|