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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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]