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,714 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DAG-based Task Dependency Resolver (Sprint 4: cf-50).
|
|
3
|
+
|
|
4
|
+
This module provides dependency resolution for multi-agent task coordination,
|
|
5
|
+
ensuring tasks are executed in correct order based on their dependencies.
|
|
6
|
+
|
|
7
|
+
Enhanced in Phase 2 with:
|
|
8
|
+
- Critical path analysis (longest path through DAG)
|
|
9
|
+
- Task slack/float calculation
|
|
10
|
+
- Parallel execution opportunity identification
|
|
11
|
+
- Dependency conflict detection and resolution suggestions
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Dict, List, Set, Optional
|
|
16
|
+
from collections import defaultdict, deque
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from codeframe.core.models import Task
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TaskTiming:
|
|
26
|
+
"""Timing information for a task in critical path analysis."""
|
|
27
|
+
|
|
28
|
+
earliest_start: float
|
|
29
|
+
earliest_finish: float
|
|
30
|
+
latest_start: float
|
|
31
|
+
latest_finish: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CriticalPathResult:
|
|
36
|
+
"""Result of critical path calculation."""
|
|
37
|
+
|
|
38
|
+
critical_task_ids: List[int]
|
|
39
|
+
total_duration: float
|
|
40
|
+
task_timings: Dict[int, TaskTiming]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DependencyConflict:
|
|
45
|
+
"""Represents a detected dependency conflict or bottleneck."""
|
|
46
|
+
|
|
47
|
+
task_id: int
|
|
48
|
+
conflict_type: str # "bottleneck", "long_chain", "high_risk_multiplier"
|
|
49
|
+
severity: str # "critical", "high", "medium"
|
|
50
|
+
recommendation: str
|
|
51
|
+
impact_analysis: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ResolutionSuggestion:
|
|
56
|
+
"""Suggested resolution for a dependency conflict."""
|
|
57
|
+
|
|
58
|
+
suggestion_type: str # "split_task", "reorder", "prioritize"
|
|
59
|
+
description: str
|
|
60
|
+
affected_task_ids: List[int]
|
|
61
|
+
expected_improvement: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DependencyResolver:
|
|
65
|
+
"""
|
|
66
|
+
DAG-based dependency resolver for task coordination.
|
|
67
|
+
|
|
68
|
+
Capabilities:
|
|
69
|
+
- Build directed acyclic graph (DAG) from task dependencies
|
|
70
|
+
- Identify ready tasks (all dependencies satisfied)
|
|
71
|
+
- Find newly unblocked tasks after completion
|
|
72
|
+
- Detect circular dependencies
|
|
73
|
+
- Validate dependencies before adding
|
|
74
|
+
- Suggest execution order via topological sort
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self):
|
|
78
|
+
"""Initialize dependency resolver."""
|
|
79
|
+
# Adjacency list: task_id -> set of task_ids it depends on
|
|
80
|
+
self.dependencies: Dict[int, Set[int]] = defaultdict(set)
|
|
81
|
+
|
|
82
|
+
# Reverse adjacency list: task_id -> set of task_ids that depend on it
|
|
83
|
+
self.dependents: Dict[int, Set[int]] = defaultdict(set)
|
|
84
|
+
|
|
85
|
+
# Track completed tasks
|
|
86
|
+
self.completed_tasks: Set[int] = set()
|
|
87
|
+
|
|
88
|
+
# Track all known tasks
|
|
89
|
+
self.all_tasks: Set[int] = set()
|
|
90
|
+
|
|
91
|
+
def build_dependency_graph(self, tasks: List[Task]) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Build dependency graph from task list.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
tasks: List of tasks with dependency information
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If circular dependencies detected
|
|
100
|
+
"""
|
|
101
|
+
# Clear existing graph
|
|
102
|
+
self.dependencies.clear()
|
|
103
|
+
self.dependents.clear()
|
|
104
|
+
self.completed_tasks.clear()
|
|
105
|
+
self.all_tasks.clear()
|
|
106
|
+
|
|
107
|
+
# First pass: register all tasks
|
|
108
|
+
for task in tasks:
|
|
109
|
+
self.all_tasks.add(task.id)
|
|
110
|
+
# Handle both v2 (TaskStatus enum) and legacy (string) status
|
|
111
|
+
status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
|
112
|
+
if status.upper() in ("DONE", "COMPLETED"):
|
|
113
|
+
self.completed_tasks.add(task.id)
|
|
114
|
+
|
|
115
|
+
# Second pass: build dependency edges
|
|
116
|
+
for task in tasks:
|
|
117
|
+
task_id = task.id
|
|
118
|
+
|
|
119
|
+
# Parse depends_on field - supports both:
|
|
120
|
+
# - List format (v2): [task_id_1, task_id_2]
|
|
121
|
+
# - String format (legacy): "[1, 2, 3]" or "1,2,3"
|
|
122
|
+
dep_ids = []
|
|
123
|
+
|
|
124
|
+
if task.depends_on:
|
|
125
|
+
if isinstance(task.depends_on, list):
|
|
126
|
+
# v2 format: already a list
|
|
127
|
+
dep_ids = task.depends_on
|
|
128
|
+
elif isinstance(task.depends_on, str) and task.depends_on.strip():
|
|
129
|
+
# Legacy string format
|
|
130
|
+
depends_on_str = task.depends_on.strip()
|
|
131
|
+
|
|
132
|
+
if depends_on_str.startswith("[") and depends_on_str.endswith("]"):
|
|
133
|
+
# JSON array format
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
dep_ids = json.loads(depends_on_str)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
logger.warning(
|
|
140
|
+
f"Invalid JSON in depends_on for task {task_id}: {depends_on_str}"
|
|
141
|
+
)
|
|
142
|
+
dep_ids = []
|
|
143
|
+
else:
|
|
144
|
+
# Comma-separated format
|
|
145
|
+
try:
|
|
146
|
+
dep_ids = [int(x.strip()) for x in depends_on_str.split(",") if x.strip()]
|
|
147
|
+
except ValueError:
|
|
148
|
+
logger.warning(
|
|
149
|
+
f"Invalid depends_on format for task {task_id}: {depends_on_str}"
|
|
150
|
+
)
|
|
151
|
+
dep_ids = []
|
|
152
|
+
|
|
153
|
+
for dep_id in dep_ids:
|
|
154
|
+
if dep_id == task_id:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Task {task_id} cannot depend on itself (self-dependency)"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if dep_id not in self.all_tasks:
|
|
160
|
+
logger.warning(
|
|
161
|
+
f"Task {task_id} depends on unknown task {dep_id}. "
|
|
162
|
+
"Dependency will be tracked but may cause blocking."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.dependencies[task_id].add(dep_id)
|
|
166
|
+
self.dependents[dep_id].add(task_id)
|
|
167
|
+
|
|
168
|
+
# Validate no cycles
|
|
169
|
+
if self.detect_cycles():
|
|
170
|
+
cycles = self._find_cycle_details()
|
|
171
|
+
raise ValueError(f"Circular dependencies detected: {cycles}")
|
|
172
|
+
|
|
173
|
+
logger.info(
|
|
174
|
+
f"Built dependency graph: {len(self.all_tasks)} tasks, "
|
|
175
|
+
f"{sum(len(deps) for deps in self.dependencies.values())} dependencies"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def get_ready_tasks(self, exclude_completed: bool = True) -> List[int]:
|
|
179
|
+
"""
|
|
180
|
+
Get tasks that are ready to execute (all dependencies satisfied).
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
exclude_completed: If True, exclude already completed tasks
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of task IDs ready for execution
|
|
187
|
+
"""
|
|
188
|
+
ready = []
|
|
189
|
+
|
|
190
|
+
for task_id in self.all_tasks:
|
|
191
|
+
# Skip completed tasks if requested
|
|
192
|
+
if exclude_completed and task_id in self.completed_tasks:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# Check if all dependencies are satisfied
|
|
196
|
+
deps = self.dependencies.get(task_id, set())
|
|
197
|
+
|
|
198
|
+
if not deps:
|
|
199
|
+
# No dependencies - always ready
|
|
200
|
+
ready.append(task_id)
|
|
201
|
+
elif deps.issubset(self.completed_tasks):
|
|
202
|
+
# All dependencies completed
|
|
203
|
+
ready.append(task_id)
|
|
204
|
+
|
|
205
|
+
return sorted(ready)
|
|
206
|
+
|
|
207
|
+
def unblock_dependent_tasks(self, completed_task_id: int) -> List[int]:
|
|
208
|
+
"""
|
|
209
|
+
Find tasks that become unblocked after a task completes.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
completed_task_id: ID of task that just completed
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of task IDs that are now unblocked
|
|
216
|
+
"""
|
|
217
|
+
# Mark task as completed
|
|
218
|
+
self.completed_tasks.add(completed_task_id)
|
|
219
|
+
|
|
220
|
+
# Find tasks that depend on this task
|
|
221
|
+
dependent_ids = self.dependents.get(completed_task_id, set())
|
|
222
|
+
|
|
223
|
+
unblocked = []
|
|
224
|
+
for dep_id in dependent_ids:
|
|
225
|
+
# Check if all dependencies are now satisfied
|
|
226
|
+
all_deps = self.dependencies.get(dep_id, set())
|
|
227
|
+
if all_deps.issubset(self.completed_tasks):
|
|
228
|
+
unblocked.append(dep_id)
|
|
229
|
+
|
|
230
|
+
logger.debug(
|
|
231
|
+
f"Task {completed_task_id} completion unblocked {len(unblocked)} tasks: {unblocked}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return sorted(unblocked)
|
|
235
|
+
|
|
236
|
+
def detect_cycles(self) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Detect if dependency graph contains cycles using DFS.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if cycles detected, False otherwise
|
|
242
|
+
"""
|
|
243
|
+
# Track visited nodes and nodes in current DFS path
|
|
244
|
+
visited = set()
|
|
245
|
+
rec_stack = set()
|
|
246
|
+
|
|
247
|
+
def has_cycle(node: int) -> bool:
|
|
248
|
+
"""DFS helper to detect cycle from node."""
|
|
249
|
+
visited.add(node)
|
|
250
|
+
rec_stack.add(node)
|
|
251
|
+
|
|
252
|
+
# Check all dependencies of this node
|
|
253
|
+
for dep in self.dependencies.get(node, set()):
|
|
254
|
+
if dep not in visited:
|
|
255
|
+
if has_cycle(dep):
|
|
256
|
+
return True
|
|
257
|
+
elif dep in rec_stack:
|
|
258
|
+
# Found a back edge (cycle)
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
rec_stack.remove(node)
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Check all nodes
|
|
265
|
+
for task_id in self.all_tasks:
|
|
266
|
+
if task_id not in visited:
|
|
267
|
+
if has_cycle(task_id):
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
def validate_dependency(self, task_id: int, depends_on_id: int) -> bool:
|
|
273
|
+
"""
|
|
274
|
+
Validate adding a dependency would not create a cycle.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
task_id: Task that will depend on another
|
|
278
|
+
depends_on_id: Task to depend on
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if dependency is valid (no cycle), False if would create cycle
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
ValueError: If self-dependency attempted
|
|
285
|
+
"""
|
|
286
|
+
if task_id == depends_on_id:
|
|
287
|
+
raise ValueError(f"Task {task_id} cannot depend on itself (self-dependency)")
|
|
288
|
+
|
|
289
|
+
# Temporarily add the dependency
|
|
290
|
+
self.dependencies[task_id].add(depends_on_id)
|
|
291
|
+
self.dependents[depends_on_id].add(task_id)
|
|
292
|
+
|
|
293
|
+
# Check for cycles
|
|
294
|
+
has_cycle = self.detect_cycles()
|
|
295
|
+
|
|
296
|
+
# Remove temporary dependency
|
|
297
|
+
self.dependencies[task_id].discard(depends_on_id)
|
|
298
|
+
self.dependents[depends_on_id].discard(task_id)
|
|
299
|
+
|
|
300
|
+
if has_cycle:
|
|
301
|
+
logger.warning(
|
|
302
|
+
f"Cannot add dependency: task {task_id} → {depends_on_id} " "would create a cycle"
|
|
303
|
+
)
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
def topological_sort(self) -> Optional[List[int]]:
|
|
309
|
+
"""
|
|
310
|
+
Compute topological ordering of tasks using Kahn's algorithm.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
List of task IDs in topological order, or None if cycle exists
|
|
314
|
+
"""
|
|
315
|
+
# Compute in-degree for each task
|
|
316
|
+
in_degree = {
|
|
317
|
+
task_id: len(self.dependencies.get(task_id, set())) for task_id in self.all_tasks
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# Queue of tasks with no dependencies
|
|
321
|
+
queue = deque([task_id for task_id in self.all_tasks if in_degree[task_id] == 0])
|
|
322
|
+
|
|
323
|
+
result = []
|
|
324
|
+
|
|
325
|
+
while queue:
|
|
326
|
+
# Process task with no remaining dependencies
|
|
327
|
+
task_id = queue.popleft()
|
|
328
|
+
result.append(task_id)
|
|
329
|
+
|
|
330
|
+
# Reduce in-degree of dependent tasks
|
|
331
|
+
for dependent_id in self.dependents.get(task_id, set()):
|
|
332
|
+
in_degree[dependent_id] -= 1
|
|
333
|
+
if in_degree[dependent_id] == 0:
|
|
334
|
+
queue.append(dependent_id)
|
|
335
|
+
|
|
336
|
+
# If we processed all tasks, we have a valid topological order
|
|
337
|
+
if len(result) == len(self.all_tasks):
|
|
338
|
+
return result
|
|
339
|
+
else:
|
|
340
|
+
# Cycle detected
|
|
341
|
+
logger.error("Cannot perform topological sort: cycle detected")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def get_dependency_depth(self, task_id: int) -> int:
|
|
345
|
+
"""
|
|
346
|
+
Get maximum dependency depth for a task (for priority calculation).
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
task_id: Task ID to analyze
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Maximum depth (0 for no dependencies, N for N levels deep)
|
|
353
|
+
"""
|
|
354
|
+
if task_id not in self.all_tasks:
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
# Use BFS to find maximum depth
|
|
358
|
+
deps = self.dependencies.get(task_id, set())
|
|
359
|
+
if not deps:
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
max_depth = 0
|
|
363
|
+
for dep_id in deps:
|
|
364
|
+
depth = 1 + self.get_dependency_depth(dep_id)
|
|
365
|
+
max_depth = max(max_depth, depth)
|
|
366
|
+
|
|
367
|
+
return max_depth
|
|
368
|
+
|
|
369
|
+
def get_blocked_tasks(self) -> Dict[int, List[int]]:
|
|
370
|
+
"""
|
|
371
|
+
Get all blocked tasks and what they're blocked by.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dict mapping task_id to list of blocking task_ids
|
|
375
|
+
"""
|
|
376
|
+
blocked = {}
|
|
377
|
+
|
|
378
|
+
for task_id in self.all_tasks:
|
|
379
|
+
if task_id in self.completed_tasks:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
deps = self.dependencies.get(task_id, set())
|
|
383
|
+
if deps:
|
|
384
|
+
# Get incomplete dependencies
|
|
385
|
+
incomplete_deps = deps - self.completed_tasks
|
|
386
|
+
if incomplete_deps:
|
|
387
|
+
blocked[task_id] = sorted(incomplete_deps)
|
|
388
|
+
|
|
389
|
+
return blocked
|
|
390
|
+
|
|
391
|
+
def _find_cycle_details(self) -> str:
|
|
392
|
+
"""
|
|
393
|
+
Find and describe a cycle in the graph (for error messages).
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
String describing the cycle
|
|
397
|
+
"""
|
|
398
|
+
visited = set()
|
|
399
|
+
rec_stack = []
|
|
400
|
+
|
|
401
|
+
def find_cycle_from(node: int) -> Optional[str]:
|
|
402
|
+
"""DFS to find cycle."""
|
|
403
|
+
visited.add(node)
|
|
404
|
+
rec_stack.append(node)
|
|
405
|
+
|
|
406
|
+
for dep in self.dependencies.get(node, set()):
|
|
407
|
+
if dep not in visited:
|
|
408
|
+
result = find_cycle_from(dep)
|
|
409
|
+
if result:
|
|
410
|
+
return result
|
|
411
|
+
elif dep in rec_stack:
|
|
412
|
+
# Found cycle
|
|
413
|
+
cycle_start = rec_stack.index(dep)
|
|
414
|
+
cycle = rec_stack[cycle_start:] + [dep]
|
|
415
|
+
return " → ".join(map(str, cycle))
|
|
416
|
+
|
|
417
|
+
rec_stack.pop()
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
for task_id in self.all_tasks:
|
|
421
|
+
if task_id not in visited:
|
|
422
|
+
cycle = find_cycle_from(task_id)
|
|
423
|
+
if cycle:
|
|
424
|
+
return cycle
|
|
425
|
+
|
|
426
|
+
return "Unknown cycle"
|
|
427
|
+
|
|
428
|
+
def clear(self) -> None:
|
|
429
|
+
"""Clear all dependency data (for testing/reset)."""
|
|
430
|
+
self.dependencies.clear()
|
|
431
|
+
self.dependents.clear()
|
|
432
|
+
self.completed_tasks.clear()
|
|
433
|
+
self.all_tasks.clear()
|
|
434
|
+
|
|
435
|
+
# ========== Phase 2: Critical Path Analysis ==========
|
|
436
|
+
|
|
437
|
+
def calculate_critical_path(
|
|
438
|
+
self, task_durations: Dict[int, float]
|
|
439
|
+
) -> CriticalPathResult:
|
|
440
|
+
"""
|
|
441
|
+
Calculate the critical path through the task dependency graph.
|
|
442
|
+
|
|
443
|
+
Uses forward and backward passes to compute earliest/latest times
|
|
444
|
+
and identifies tasks with zero slack (critical path).
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
task_durations: Dict mapping task_id to duration in hours.
|
|
448
|
+
Missing tasks default to 0 duration.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
CriticalPathResult with critical task IDs, total duration, and timings
|
|
452
|
+
"""
|
|
453
|
+
# Get topological order
|
|
454
|
+
topo_order = self.topological_sort()
|
|
455
|
+
if not topo_order:
|
|
456
|
+
# If cycle detected, return empty result
|
|
457
|
+
return CriticalPathResult(
|
|
458
|
+
critical_task_ids=[],
|
|
459
|
+
total_duration=0.0,
|
|
460
|
+
task_timings={},
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Initialize timing data
|
|
464
|
+
task_timings: Dict[int, TaskTiming] = {}
|
|
465
|
+
|
|
466
|
+
# Forward pass: compute earliest start/finish times
|
|
467
|
+
earliest_start: Dict[int, float] = {}
|
|
468
|
+
earliest_finish: Dict[int, float] = {}
|
|
469
|
+
|
|
470
|
+
for task_id in topo_order:
|
|
471
|
+
duration = task_durations.get(task_id, 0.0)
|
|
472
|
+
deps = self.dependencies.get(task_id, set())
|
|
473
|
+
|
|
474
|
+
if not deps:
|
|
475
|
+
# No dependencies - starts at time 0
|
|
476
|
+
earliest_start[task_id] = 0.0
|
|
477
|
+
else:
|
|
478
|
+
# Earliest start is max of all dependency finish times
|
|
479
|
+
earliest_start[task_id] = max(
|
|
480
|
+
earliest_finish.get(dep_id, 0.0) for dep_id in deps
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
earliest_finish[task_id] = earliest_start[task_id] + duration
|
|
484
|
+
|
|
485
|
+
# Project end time is the maximum earliest finish
|
|
486
|
+
project_duration = max(earliest_finish.values()) if earliest_finish else 0.0
|
|
487
|
+
|
|
488
|
+
# Backward pass: compute latest start/finish times
|
|
489
|
+
latest_finish: Dict[int, float] = {}
|
|
490
|
+
latest_start: Dict[int, float] = {}
|
|
491
|
+
|
|
492
|
+
for task_id in reversed(topo_order):
|
|
493
|
+
duration = task_durations.get(task_id, 0.0)
|
|
494
|
+
dependents = self.dependents.get(task_id, set())
|
|
495
|
+
|
|
496
|
+
if not dependents:
|
|
497
|
+
# No dependents - must finish by project end
|
|
498
|
+
latest_finish[task_id] = project_duration
|
|
499
|
+
else:
|
|
500
|
+
# Latest finish is min of all dependent start times
|
|
501
|
+
latest_finish[task_id] = min(
|
|
502
|
+
latest_start.get(dep_id, project_duration) for dep_id in dependents
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
latest_start[task_id] = latest_finish[task_id] - duration
|
|
506
|
+
|
|
507
|
+
# Build timing objects and identify critical path (zero slack)
|
|
508
|
+
critical_task_ids = []
|
|
509
|
+
|
|
510
|
+
for task_id in self.all_tasks:
|
|
511
|
+
timing = TaskTiming(
|
|
512
|
+
earliest_start=earliest_start.get(task_id, 0.0),
|
|
513
|
+
earliest_finish=earliest_finish.get(task_id, 0.0),
|
|
514
|
+
latest_start=latest_start.get(task_id, 0.0),
|
|
515
|
+
latest_finish=latest_finish.get(task_id, 0.0),
|
|
516
|
+
)
|
|
517
|
+
task_timings[task_id] = timing
|
|
518
|
+
|
|
519
|
+
# Task is on critical path if slack is zero
|
|
520
|
+
slack = timing.latest_start - timing.earliest_start
|
|
521
|
+
if abs(slack) < 0.001: # Float comparison tolerance
|
|
522
|
+
critical_task_ids.append(task_id)
|
|
523
|
+
|
|
524
|
+
return CriticalPathResult(
|
|
525
|
+
critical_task_ids=sorted(critical_task_ids),
|
|
526
|
+
total_duration=project_duration,
|
|
527
|
+
task_timings=task_timings,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def calculate_task_slack(self, task_durations: Dict[int, float]) -> Dict[int, float]:
|
|
531
|
+
"""
|
|
532
|
+
Calculate slack/float time for each task.
|
|
533
|
+
|
|
534
|
+
Slack = Latest Start - Earliest Start
|
|
535
|
+
Tasks with zero slack are on the critical path.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
task_durations: Dict mapping task_id to duration in hours
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Dict mapping task_id to slack time in hours
|
|
542
|
+
"""
|
|
543
|
+
result = self.calculate_critical_path(task_durations)
|
|
544
|
+
|
|
545
|
+
slack = {}
|
|
546
|
+
for task_id, timing in result.task_timings.items():
|
|
547
|
+
slack[task_id] = timing.latest_start - timing.earliest_start
|
|
548
|
+
|
|
549
|
+
return slack
|
|
550
|
+
|
|
551
|
+
def identify_parallel_opportunities(self) -> Dict[int, List[int]]:
|
|
552
|
+
"""
|
|
553
|
+
Identify tasks that can execute in parallel (execution waves).
|
|
554
|
+
|
|
555
|
+
Groups tasks by their dependency level - tasks in the same wave
|
|
556
|
+
have no dependencies on each other and can run concurrently.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Dict mapping wave number (0, 1, 2...) to list of task IDs
|
|
560
|
+
"""
|
|
561
|
+
# Use topological sort with level tracking
|
|
562
|
+
in_degree = {
|
|
563
|
+
task_id: len(self.dependencies.get(task_id, set()))
|
|
564
|
+
for task_id in self.all_tasks
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# Track which level each task belongs to
|
|
568
|
+
task_level: Dict[int, int] = {}
|
|
569
|
+
|
|
570
|
+
# Start with tasks that have no dependencies (level 0)
|
|
571
|
+
current_level = 0
|
|
572
|
+
queue = deque([task_id for task_id in self.all_tasks if in_degree[task_id] == 0])
|
|
573
|
+
|
|
574
|
+
while queue:
|
|
575
|
+
# Process all tasks at current level
|
|
576
|
+
level_size = len(queue)
|
|
577
|
+
|
|
578
|
+
for _ in range(level_size):
|
|
579
|
+
task_id = queue.popleft()
|
|
580
|
+
task_level[task_id] = current_level
|
|
581
|
+
|
|
582
|
+
# Add dependents to next level if their in-degree becomes 0
|
|
583
|
+
for dependent_id in self.dependents.get(task_id, set()):
|
|
584
|
+
in_degree[dependent_id] -= 1
|
|
585
|
+
if in_degree[dependent_id] == 0:
|
|
586
|
+
queue.append(dependent_id)
|
|
587
|
+
|
|
588
|
+
current_level += 1
|
|
589
|
+
|
|
590
|
+
# Group tasks by level
|
|
591
|
+
waves: Dict[int, List[int]] = defaultdict(list)
|
|
592
|
+
for task_id, level in task_level.items():
|
|
593
|
+
waves[level].append(task_id)
|
|
594
|
+
|
|
595
|
+
# Sort task IDs within each wave
|
|
596
|
+
return {level: sorted(tasks) for level, tasks in waves.items()}
|
|
597
|
+
|
|
598
|
+
# ========== Phase 2: Conflict Detection ==========
|
|
599
|
+
|
|
600
|
+
def detect_dependency_conflicts(
|
|
601
|
+
self, task_durations: Dict[int, float]
|
|
602
|
+
) -> List[DependencyConflict]:
|
|
603
|
+
"""
|
|
604
|
+
Detect dependency conflicts, bottlenecks, and risk patterns.
|
|
605
|
+
|
|
606
|
+
Identifies:
|
|
607
|
+
- Bottleneck tasks (many dependents, high impact on critical path)
|
|
608
|
+
- Long dependency chains (> 5 tasks in sequence)
|
|
609
|
+
- High-risk multipliers (high complexity + many dependents)
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
task_durations: Dict mapping task_id to duration in hours
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
List of DependencyConflict objects with severity and recommendations
|
|
616
|
+
"""
|
|
617
|
+
conflicts: List[DependencyConflict] = []
|
|
618
|
+
|
|
619
|
+
# Get critical path info
|
|
620
|
+
cp_result = self.calculate_critical_path(task_durations)
|
|
621
|
+
critical_set = set(cp_result.critical_task_ids)
|
|
622
|
+
|
|
623
|
+
# Detect bottleneck tasks (3+ dependents)
|
|
624
|
+
bottleneck_threshold = 3
|
|
625
|
+
for task_id in self.all_tasks:
|
|
626
|
+
dependent_count = len(self.dependents.get(task_id, set()))
|
|
627
|
+
|
|
628
|
+
if dependent_count >= bottleneck_threshold:
|
|
629
|
+
duration = task_durations.get(task_id, 0.0)
|
|
630
|
+
is_critical = task_id in critical_set
|
|
631
|
+
|
|
632
|
+
severity = "critical" if is_critical and duration > 4 else "high" if is_critical else "medium"
|
|
633
|
+
|
|
634
|
+
conflicts.append(
|
|
635
|
+
DependencyConflict(
|
|
636
|
+
task_id=task_id,
|
|
637
|
+
conflict_type="bottleneck",
|
|
638
|
+
severity=severity,
|
|
639
|
+
recommendation=f"Consider splitting task {task_id} into smaller tasks "
|
|
640
|
+
f"to reduce blocking impact on {dependent_count} dependent tasks",
|
|
641
|
+
impact_analysis=f"Task {task_id} blocks {dependent_count} tasks. "
|
|
642
|
+
f"Duration: {duration}h. On critical path: {is_critical}",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Detect long dependency chains (> 5 tasks)
|
|
647
|
+
chain_threshold = 5
|
|
648
|
+
for task_id in self.all_tasks:
|
|
649
|
+
depth = self.get_dependency_depth(task_id)
|
|
650
|
+
if depth >= chain_threshold:
|
|
651
|
+
conflicts.append(
|
|
652
|
+
DependencyConflict(
|
|
653
|
+
task_id=task_id,
|
|
654
|
+
conflict_type="long_chain",
|
|
655
|
+
severity="high" if task_id in critical_set else "medium",
|
|
656
|
+
recommendation=f"Consider parallelizing some tasks in the chain "
|
|
657
|
+
f"leading to task {task_id}",
|
|
658
|
+
impact_analysis=f"Task {task_id} has dependency depth of {depth}, "
|
|
659
|
+
f"creating a long sequential chain",
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
return conflicts
|
|
664
|
+
|
|
665
|
+
def suggest_dependency_resolution(
|
|
666
|
+
self, conflicts: List[DependencyConflict]
|
|
667
|
+
) -> List[ResolutionSuggestion]:
|
|
668
|
+
"""
|
|
669
|
+
Generate resolution suggestions for detected conflicts.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
conflicts: List of detected dependency conflicts
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
List of ResolutionSuggestion objects
|
|
676
|
+
"""
|
|
677
|
+
suggestions: List[ResolutionSuggestion] = []
|
|
678
|
+
|
|
679
|
+
for conflict in conflicts:
|
|
680
|
+
if conflict.conflict_type == "bottleneck":
|
|
681
|
+
# Suggest task splitting
|
|
682
|
+
suggestions.append(
|
|
683
|
+
ResolutionSuggestion(
|
|
684
|
+
suggestion_type="split_task",
|
|
685
|
+
description=f"Split task {conflict.task_id} into multiple smaller tasks "
|
|
686
|
+
f"that can be worked on independently",
|
|
687
|
+
affected_task_ids=[conflict.task_id],
|
|
688
|
+
expected_improvement="Reduces blocking time and enables more parallel work",
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Suggest prioritization
|
|
693
|
+
suggestions.append(
|
|
694
|
+
ResolutionSuggestion(
|
|
695
|
+
suggestion_type="prioritize",
|
|
696
|
+
description=f"Prioritize task {conflict.task_id} to unblock dependent tasks sooner",
|
|
697
|
+
affected_task_ids=[conflict.task_id],
|
|
698
|
+
expected_improvement="Earlier completion of blocking task reduces overall delay",
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
elif conflict.conflict_type == "long_chain":
|
|
703
|
+
# Suggest dependency reordering
|
|
704
|
+
suggestions.append(
|
|
705
|
+
ResolutionSuggestion(
|
|
706
|
+
suggestion_type="reorder",
|
|
707
|
+
description=f"Review dependencies leading to task {conflict.task_id} "
|
|
708
|
+
f"- some may be removable or parallelizable",
|
|
709
|
+
affected_task_ids=[conflict.task_id],
|
|
710
|
+
expected_improvement="Shorter critical path reduces project duration",
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return suggestions
|