misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
misterdev/agent.py
ADDED
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markdown import Markdown
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
|
|
13
|
+
from misterdev.core.execution.registry import ProjectRegistry
|
|
14
|
+
from misterdev.core.modes import (
|
|
15
|
+
BuildMode,
|
|
16
|
+
BuildFlags,
|
|
17
|
+
parse_flags,
|
|
18
|
+
resolve_mode,
|
|
19
|
+
)
|
|
20
|
+
from misterdev.core.planning.assessment import (
|
|
21
|
+
ProjectAssessment,
|
|
22
|
+
HealthCheck,
|
|
23
|
+
)
|
|
24
|
+
from misterdev.core.context.scratchpad import Scratchpad
|
|
25
|
+
from misterdev.core.planning.decomposer import (
|
|
26
|
+
decompose_spec,
|
|
27
|
+
topological_sort,
|
|
28
|
+
format_plan,
|
|
29
|
+
)
|
|
30
|
+
from misterdev.core.verification.validator import ValidationResult
|
|
31
|
+
from misterdev.core.verification.gatekeeper import GateKeeper
|
|
32
|
+
from misterdev.core.gitcmd import run_git
|
|
33
|
+
from misterdev.core.planning.sovereign import (
|
|
34
|
+
StrategyOptimizer,
|
|
35
|
+
RealTimeAligner,
|
|
36
|
+
ABMCTSPlanner,
|
|
37
|
+
EphemeralCodeManager,
|
|
38
|
+
ProbeGenerator,
|
|
39
|
+
)
|
|
40
|
+
from misterdev.core.planning.metacognition import SessionAuditor
|
|
41
|
+
from misterdev.core.context.contracts import ContractRegistry
|
|
42
|
+
from misterdev.core.verification.preflight import PreflightValidator
|
|
43
|
+
from misterdev.core.execution.progress import (
|
|
44
|
+
ProgressTracker,
|
|
45
|
+
compute_task_hash,
|
|
46
|
+
)
|
|
47
|
+
from misterdev.core.context.change_tracker import ChangeTracker
|
|
48
|
+
from misterdev.core.reporting.report import BuildReport
|
|
49
|
+
from misterdev.core.execution.project import Project
|
|
50
|
+
from misterdev.core.models import Task
|
|
51
|
+
from misterdev.analyzers.project_analyzer import (
|
|
52
|
+
analyze_project,
|
|
53
|
+
)
|
|
54
|
+
from misterdev.core.planning.advisor import recommend_work
|
|
55
|
+
from misterdev.llm.client import BudgetExceededError
|
|
56
|
+
from misterdev.task_executors.markdown_plan_executor import (
|
|
57
|
+
MarkdownPlanExecutor,
|
|
58
|
+
)
|
|
59
|
+
from misterdev.agent_helpers import (
|
|
60
|
+
ProgressReporter,
|
|
61
|
+
_WorktreeProjectView,
|
|
62
|
+
_apply_budget_ceiling,
|
|
63
|
+
_budget_exhausted,
|
|
64
|
+
_check_golden_config,
|
|
65
|
+
_combine_commands,
|
|
66
|
+
_warn_if_baseline_broken,
|
|
67
|
+
_warn_if_no_test_gate,
|
|
68
|
+
)
|
|
69
|
+
from misterdev.config import get_setting
|
|
70
|
+
from misterdev.logging_setup import setup_logger
|
|
71
|
+
|
|
72
|
+
logger = setup_logger(__name__)
|
|
73
|
+
console = Console()
|
|
74
|
+
|
|
75
|
+
MAX_CONSECUTIVE_FAILURES = 3
|
|
76
|
+
|
|
77
|
+
# Safety backstop for "auto" convergence: the loop normally stops earlier on a
|
|
78
|
+
# green gate, budget exhaustion, or no-progress, but this bounds a pathological
|
|
79
|
+
# run that keeps producing genuinely different (yet still failing) fix tasks.
|
|
80
|
+
CONVERGENCE_CEILING = 25
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProjectOrchestrator:
|
|
84
|
+
"""Main orchestrator with Sovereign Grounded workflow."""
|
|
85
|
+
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.registry = ProjectRegistry()
|
|
88
|
+
self.last_build_succeeded = True
|
|
89
|
+
|
|
90
|
+
def scan_directory(self, path: str | Path):
|
|
91
|
+
self.registry.discover_projects(path)
|
|
92
|
+
|
|
93
|
+
def list_projects(self) -> Dict[str, Any]:
|
|
94
|
+
return self.registry.list_projects()
|
|
95
|
+
|
|
96
|
+
def get_project_status(self, project_path: str | Path) -> Dict[str, Any]:
|
|
97
|
+
project = self.registry.get_project(project_path)
|
|
98
|
+
if not project:
|
|
99
|
+
try:
|
|
100
|
+
project = self.registry.register_project(project_path)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Failed to load project at {project_path}: {e}")
|
|
103
|
+
return {"error": f"Project load failed: {e}"}
|
|
104
|
+
project.task_manager.discover_tasks()
|
|
105
|
+
return {
|
|
106
|
+
"name": project.name,
|
|
107
|
+
"path": str(project.path),
|
|
108
|
+
"description": project.description,
|
|
109
|
+
"tasks": [
|
|
110
|
+
{"id": t.id, "status": t.status, "description": t.description[:100]}
|
|
111
|
+
for t in project.task_manager.tasks.values()
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def run_project(
|
|
116
|
+
self,
|
|
117
|
+
project_path: str | Path,
|
|
118
|
+
dry_run: bool = False,
|
|
119
|
+
skip_preflight: bool = False,
|
|
120
|
+
force: bool = False,
|
|
121
|
+
status: bool = False,
|
|
122
|
+
):
|
|
123
|
+
"""Run pending devplan tasks with dependency-aware orchestration.
|
|
124
|
+
|
|
125
|
+
Unlike build(), this executes a pre-written devplan: it skips the
|
|
126
|
+
analysis/spec/decomposition/gate phases but adds topological
|
|
127
|
+
ordering, progress-based crash recovery, contract injection, scratchpad
|
|
128
|
+
learning, and change tracking around the existing markdown tasks.
|
|
129
|
+
"""
|
|
130
|
+
project = self._get_or_register(project_path)
|
|
131
|
+
if not project:
|
|
132
|
+
return
|
|
133
|
+
_check_golden_config(project.config)
|
|
134
|
+
if project.env_manager:
|
|
135
|
+
project.env_manager.setup()
|
|
136
|
+
project.task_manager.discover_tasks()
|
|
137
|
+
pending = project.task_manager.get_pending_tasks()
|
|
138
|
+
if not pending:
|
|
139
|
+
logger.info(f"No pending tasks for {project.name}.")
|
|
140
|
+
return
|
|
141
|
+
tasks = topological_sort(pending)
|
|
142
|
+
|
|
143
|
+
if not skip_preflight:
|
|
144
|
+
issues = PreflightValidator().validate(tasks, project.path)
|
|
145
|
+
for issue in issues:
|
|
146
|
+
log = logger.error if issue.severity == "error" else logger.warning
|
|
147
|
+
log(f"Preflight {issue.severity}: {issue.task_id}: {issue.message}")
|
|
148
|
+
if PreflightValidator.has_errors(issues):
|
|
149
|
+
logger.error(
|
|
150
|
+
"Preflight validation failed; aborting. Use --skip-preflight to override."
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if dry_run:
|
|
155
|
+
self._print_execution_plan(tasks)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if status:
|
|
159
|
+
self._print_rerun_status(
|
|
160
|
+
tasks, ProgressTracker(project.path), project.path, force
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
scratchpad = Scratchpad()
|
|
165
|
+
contracts = ContractRegistry(project.path)
|
|
166
|
+
progress = ProgressTracker(project.path)
|
|
167
|
+
changes = ChangeTracker(project.path)
|
|
168
|
+
strategy_optimizer = StrategyOptimizer()
|
|
169
|
+
executor = MarkdownPlanExecutor(scratchpad=scratchpad)
|
|
170
|
+
lang = (project.config.get("language") or "python").lower()
|
|
171
|
+
max_consecutive_failures = get_setting(
|
|
172
|
+
project.config, "orchestrator", "max_consecutive_failures"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
completed_ids = set(progress.completed)
|
|
176
|
+
failed_ids: set[str] = set()
|
|
177
|
+
consecutive_failures = 0
|
|
178
|
+
if completed_ids:
|
|
179
|
+
logger.info(f"Resuming run: {len(completed_ids)} tasks already completed")
|
|
180
|
+
logger.info(f"Running {len(tasks)} pending tasks for {project.name}.")
|
|
181
|
+
|
|
182
|
+
reporter = ProgressReporter(len(tasks))
|
|
183
|
+
remaining = list(tasks)
|
|
184
|
+
aborted = False
|
|
185
|
+
wave = 0
|
|
186
|
+
while remaining and not aborted:
|
|
187
|
+
ready, still_waiting = [], []
|
|
188
|
+
for task in remaining:
|
|
189
|
+
if not force and not progress.needs_rerun(
|
|
190
|
+
task.id, compute_task_hash(task, project.path)
|
|
191
|
+
):
|
|
192
|
+
completed_ids.add(task.id)
|
|
193
|
+
continue
|
|
194
|
+
if progress.is_done(task.id):
|
|
195
|
+
logger.info(
|
|
196
|
+
f"Task {task.id} previously completed but inputs changed; re-running."
|
|
197
|
+
)
|
|
198
|
+
if any(d in failed_ids for d in task.dependencies):
|
|
199
|
+
failed_ids.add(task.id)
|
|
200
|
+
logger.warning(f"Skipping {task.id}: a dependency failed.")
|
|
201
|
+
continue
|
|
202
|
+
if any(d not in completed_ids for d in task.dependencies):
|
|
203
|
+
still_waiting.append(task)
|
|
204
|
+
continue
|
|
205
|
+
ready.append(task)
|
|
206
|
+
|
|
207
|
+
if not ready:
|
|
208
|
+
if still_waiting:
|
|
209
|
+
logger.error(
|
|
210
|
+
f"Dependency deadlock; unresolved tasks: {[t.id for t in still_waiting]}"
|
|
211
|
+
)
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
wave += 1
|
|
215
|
+
reporter.start_wave(wave, [t.id for t in ready])
|
|
216
|
+
for task in ready:
|
|
217
|
+
reporter.start_task(task.id, task.title or task.description[:50])
|
|
218
|
+
try:
|
|
219
|
+
self._inject_task_context(
|
|
220
|
+
task, contracts, changes, strategy_optimizer, project
|
|
221
|
+
)
|
|
222
|
+
result = executor.execute(task, project)
|
|
223
|
+
task.execution_history.append(result)
|
|
224
|
+
except BudgetExceededError as e:
|
|
225
|
+
# Terminal (budget spent or account out of credits): stop the
|
|
226
|
+
# whole run cleanly rather than crash or churn the next tasks.
|
|
227
|
+
logger.warning(f"Halting run: {e}")
|
|
228
|
+
aborted = True
|
|
229
|
+
break
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error(f"Task {task.id} raised: {e}")
|
|
232
|
+
result = None
|
|
233
|
+
|
|
234
|
+
succeeded = result is not None and result.status == "completed"
|
|
235
|
+
reporter.end_task(task.id, succeeded)
|
|
236
|
+
if succeeded:
|
|
237
|
+
completed_ids.add(task.id)
|
|
238
|
+
progress.mark_completed(
|
|
239
|
+
task.id, compute_task_hash(task, project.path)
|
|
240
|
+
)
|
|
241
|
+
consecutive_failures = 0
|
|
242
|
+
modified = task.files_to_modify + task.files_to_create
|
|
243
|
+
if modified:
|
|
244
|
+
contracts.extract_contracts(
|
|
245
|
+
task.id,
|
|
246
|
+
modified,
|
|
247
|
+
project.path,
|
|
248
|
+
project.llm_client,
|
|
249
|
+
language=lang,
|
|
250
|
+
)
|
|
251
|
+
changes.record_task_changes(task.id, modified)
|
|
252
|
+
else:
|
|
253
|
+
failed_ids.add(task.id)
|
|
254
|
+
progress.mark_failed(task.id)
|
|
255
|
+
consecutive_failures += 1
|
|
256
|
+
if consecutive_failures >= max_consecutive_failures:
|
|
257
|
+
logger.error("Aborting run: too many consecutive failures.")
|
|
258
|
+
aborted = True
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
remaining = still_waiting
|
|
262
|
+
|
|
263
|
+
reporter.summary()
|
|
264
|
+
|
|
265
|
+
def _inject_task_context(
|
|
266
|
+
self, task, contracts, changes, strategy_optimizer, project
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Populate a task's processor_data with contracts, recent changes, and strategy."""
|
|
269
|
+
task.processor_data["interface_contracts"] = contracts.get_contracts_for_task(
|
|
270
|
+
task.dependencies
|
|
271
|
+
)
|
|
272
|
+
task.processor_data["recent_changes"] = changes.get_recent_changes_for_files(
|
|
273
|
+
task.files_to_modify + task.files_to_create
|
|
274
|
+
)
|
|
275
|
+
task.processor_data["strategy"] = strategy_optimizer.select_best_strategy(
|
|
276
|
+
task.description, task.category, "", project.llm_client
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _print_execution_plan(self, tasks: list[Task]) -> None:
|
|
280
|
+
"""Print the dependency-ordered wave plan without executing anything."""
|
|
281
|
+
completed: set[str] = set()
|
|
282
|
+
remaining = list(tasks)
|
|
283
|
+
wave = 0
|
|
284
|
+
console.print(f"\n[bold]Execution Plan (dry-run): {len(tasks)} tasks[/]")
|
|
285
|
+
while remaining:
|
|
286
|
+
ready = [
|
|
287
|
+
t for t in remaining if all(d in completed for d in t.dependencies)
|
|
288
|
+
]
|
|
289
|
+
if not ready:
|
|
290
|
+
console.print(
|
|
291
|
+
f"[red]Dependency deadlock among: {[t.id for t in remaining]}[/]"
|
|
292
|
+
)
|
|
293
|
+
break
|
|
294
|
+
wave += 1
|
|
295
|
+
console.print(f"\n[bold cyan]Wave {wave}[/] ({len(ready)} parallel):")
|
|
296
|
+
for t in ready:
|
|
297
|
+
deps = f" -> depends on {t.dependencies}" if t.dependencies else ""
|
|
298
|
+
console.print(
|
|
299
|
+
f" [{t.id}] {t.title or t.description[:50]} ({t.complexity}, {t.category}){deps}"
|
|
300
|
+
)
|
|
301
|
+
completed.add(t.id)
|
|
302
|
+
remaining = [t for t in remaining if t.id not in completed]
|
|
303
|
+
console.print(f"\n[dim]Total: {len(tasks)} tasks, {wave} waves.[/]\n")
|
|
304
|
+
|
|
305
|
+
def _print_rerun_status(
|
|
306
|
+
self, tasks: list[Task], progress, project_path, force: bool
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Show which tasks would run vs skip, based on content hashes."""
|
|
309
|
+
console.print(f"\n[bold]Task status: {len(tasks)} tasks[/]")
|
|
310
|
+
run = 0
|
|
311
|
+
for t in tasks:
|
|
312
|
+
rerun = force or progress.needs_rerun(
|
|
313
|
+
t.id, compute_task_hash(t, project_path)
|
|
314
|
+
)
|
|
315
|
+
if rerun:
|
|
316
|
+
run += 1
|
|
317
|
+
reason = (
|
|
318
|
+
"forced"
|
|
319
|
+
if force
|
|
320
|
+
else ("changed" if progress.is_done(t.id) else "pending")
|
|
321
|
+
)
|
|
322
|
+
console.print(f" [yellow]RUN [/] {t.id} ({reason})")
|
|
323
|
+
else:
|
|
324
|
+
console.print(f" [green]SKIP[/] {t.id} (unchanged, completed)")
|
|
325
|
+
console.print(f"\n[dim]{run} would run, {len(tasks) - run} would skip.[/]\n")
|
|
326
|
+
|
|
327
|
+
def run_task(self, project_path: str | Path, task_id: str):
|
|
328
|
+
"""Runs a specific task for a given project."""
|
|
329
|
+
project = self._get_or_register(project_path)
|
|
330
|
+
if not project:
|
|
331
|
+
return
|
|
332
|
+
if project.env_manager:
|
|
333
|
+
project.env_manager.setup()
|
|
334
|
+
project.task_manager.discover_tasks()
|
|
335
|
+
task = project.task_manager.tasks.get(task_id)
|
|
336
|
+
if not task:
|
|
337
|
+
logger.error(f"Task {task_id} not found in {project.name}.")
|
|
338
|
+
return
|
|
339
|
+
# Inject contracts from this task's already-completed dependencies.
|
|
340
|
+
contracts = ContractRegistry(project.path)
|
|
341
|
+
task.processor_data["interface_contracts"] = contracts.get_contracts_for_task(
|
|
342
|
+
task.dependencies
|
|
343
|
+
)
|
|
344
|
+
executor = MarkdownPlanExecutor()
|
|
345
|
+
try:
|
|
346
|
+
result = executor.execute(task, project)
|
|
347
|
+
task.execution_history.append(result)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Task {task_id} raised: {e}")
|
|
350
|
+
project.task_manager.update_task_status(task_id, "failed")
|
|
351
|
+
|
|
352
|
+
def build(self, project_path: str | Path, args: str = "") -> str:
|
|
353
|
+
project = self._get_or_register(project_path)
|
|
354
|
+
if not project:
|
|
355
|
+
return "Error: could not load project"
|
|
356
|
+
|
|
357
|
+
arg_list = args.split() if args else []
|
|
358
|
+
remaining, flags = parse_flags(arg_list)
|
|
359
|
+
prompt = " ".join(remaining)
|
|
360
|
+
mode = resolve_mode(prompt, project.path)
|
|
361
|
+
|
|
362
|
+
logger.info(f"Build started: mode={mode.value}, flags={flags}")
|
|
363
|
+
start_time = datetime.now(timezone.utc)
|
|
364
|
+
|
|
365
|
+
# Refuse to run on a dirty working tree: branch-per-task execution
|
|
366
|
+
# carries and can sweep/revert uncommitted changes, so a second writer
|
|
367
|
+
# (or the user's own in-progress work) would be corrupted. Override with
|
|
368
|
+
# --allow-dirty. Skipped for dry-run (read-only).
|
|
369
|
+
if not flags.dry_run and not flags.allow_dirty:
|
|
370
|
+
dirty = self._working_tree_dirty(project)
|
|
371
|
+
if dirty:
|
|
372
|
+
logger.error("Refusing to build on a dirty working tree.")
|
|
373
|
+
return (
|
|
374
|
+
f"Error: working tree has uncommitted changes ({dirty}). "
|
|
375
|
+
"Commit or stash them first, or pass --allow-dirty to override."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Propagate budget to LLM client
|
|
379
|
+
_apply_budget_ceiling(project.llm_client, flags.budget)
|
|
380
|
+
|
|
381
|
+
# Preflight: fail fast on a retired/misrouted model id before spending
|
|
382
|
+
# the analysis phase on calls that would 404 mid-run.
|
|
383
|
+
if not flags.dry_run:
|
|
384
|
+
ok, detail = project.llm_client.health_check()
|
|
385
|
+
if not ok:
|
|
386
|
+
logger.error(f"Model preflight failed: {detail}")
|
|
387
|
+
return f"Error: {detail}. Set a valid model in config before building."
|
|
388
|
+
|
|
389
|
+
env_activate = self._setup_env(project)
|
|
390
|
+
|
|
391
|
+
# The budget ceiling is a graceful kill-switch, not a crash: a
|
|
392
|
+
# BudgetExceededError can surface from ANY model call (an analysis
|
|
393
|
+
# analyzer, probe discovery, spec generation, decomposition, or a task),
|
|
394
|
+
# so wrap the whole pipeline and degrade to a partial report instead of
|
|
395
|
+
# letting the CLI die with a traceback. (Found by dogfooding: a $2 cap was
|
|
396
|
+
# exhausted by pre-execution analysis+probes+spec and crashed the run.)
|
|
397
|
+
report = None
|
|
398
|
+
try:
|
|
399
|
+
# Phase 1: Analysis
|
|
400
|
+
assessment = self._analyze(project, env_activate)
|
|
401
|
+
|
|
402
|
+
report = BuildReport(mode, project.name, assessment, start_time)
|
|
403
|
+
report.health_before = assessment.health.model_copy()
|
|
404
|
+
_warn_if_baseline_broken(assessment, report)
|
|
405
|
+
_warn_if_no_test_gate(assessment, project, report)
|
|
406
|
+
|
|
407
|
+
return self._run_pipeline(
|
|
408
|
+
project, prompt, mode, flags, assessment, env_activate, report
|
|
409
|
+
)
|
|
410
|
+
except BudgetExceededError as e:
|
|
411
|
+
return self._halt_on_budget(project, report, e)
|
|
412
|
+
|
|
413
|
+
def _halt_on_budget(
|
|
414
|
+
self, project: Project, report: Optional[BuildReport], error: Exception
|
|
415
|
+
) -> str:
|
|
416
|
+
"""Degrade a budget-exhausted run to a partial report (never a traceback).
|
|
417
|
+
|
|
418
|
+
Records the halt, finalizes whatever work completed, and returns the
|
|
419
|
+
report markdown. When the cap is hit before the report exists (during
|
|
420
|
+
analysis), returns a concise message instead.
|
|
421
|
+
"""
|
|
422
|
+
self.last_build_succeeded = False
|
|
423
|
+
logger.error(f"Build halted by budget ceiling: {error}")
|
|
424
|
+
if report is None:
|
|
425
|
+
return (
|
|
426
|
+
f"Build halted: {error}. The budget ceiling stopped the run during "
|
|
427
|
+
"analysis, before any task executed. Raise --budget to proceed."
|
|
428
|
+
)
|
|
429
|
+
report.key_decisions.append(f"Halted by budget ceiling: {error}")
|
|
430
|
+
report.finalize()
|
|
431
|
+
usage = project.llm_client.cumulative_usage
|
|
432
|
+
report.llm_calls = usage.call_count
|
|
433
|
+
report.llm_tokens = usage.total_tokens
|
|
434
|
+
report.llm_prompt_tokens = getattr(usage, "prompt_tokens", 0)
|
|
435
|
+
report.llm_completion_tokens = getattr(usage, "completion_tokens", 0)
|
|
436
|
+
report.llm_cache_read_tokens = getattr(usage, "cache_read_tokens", 0)
|
|
437
|
+
report.llm_cost = usage.estimated_cost
|
|
438
|
+
report.save(project.path)
|
|
439
|
+
return report.to_markdown()
|
|
440
|
+
|
|
441
|
+
def interactive_plan(self, project_path: str | Path, args: str = "") -> str:
|
|
442
|
+
"""Analyze the project, recommend work, and compose a plan with the user.
|
|
443
|
+
|
|
444
|
+
The entry point for a plain `misterdev` invocation: instead
|
|
445
|
+
of a predefined devplan, it reads the live project state, proposes
|
|
446
|
+
ranked work items, lets the user choose (or type their own goal), then
|
|
447
|
+
composes and confirms the plan before executing.
|
|
448
|
+
"""
|
|
449
|
+
project = self._get_or_register(project_path)
|
|
450
|
+
if not project:
|
|
451
|
+
return "Error: could not load project"
|
|
452
|
+
|
|
453
|
+
_, flags = parse_flags(args.split() if args else [])
|
|
454
|
+
start_time = datetime.now(timezone.utc)
|
|
455
|
+
_apply_budget_ceiling(project.llm_client, flags.budget)
|
|
456
|
+
|
|
457
|
+
if not flags.allow_dirty:
|
|
458
|
+
dirty = self._working_tree_dirty(project)
|
|
459
|
+
if dirty:
|
|
460
|
+
console.print(
|
|
461
|
+
f"[red]Working tree has uncommitted changes ({dirty}).[/] "
|
|
462
|
+
"Commit or stash first, or pass --allow-dirty."
|
|
463
|
+
)
|
|
464
|
+
return f"Error: dirty working tree ({dirty})."
|
|
465
|
+
|
|
466
|
+
ok, detail = project.llm_client.health_check()
|
|
467
|
+
if not ok:
|
|
468
|
+
console.print(f"[red]Model preflight failed:[/] {detail}")
|
|
469
|
+
return f"Error: {detail}. Set a valid model in config before building."
|
|
470
|
+
|
|
471
|
+
env_activate = self._setup_env(project)
|
|
472
|
+
|
|
473
|
+
report = None
|
|
474
|
+
try:
|
|
475
|
+
console.print(f"[bold]Analyzing[/] {project.name} ...")
|
|
476
|
+
assessment = self._analyze(project, env_activate)
|
|
477
|
+
console.print(
|
|
478
|
+
Panel(assessment.summary(), title="Current state", expand=False)
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
recs = recommend_work(assessment, project.llm_client)
|
|
482
|
+
goal, mode = self._choose_goal(recs)
|
|
483
|
+
if goal is None:
|
|
484
|
+
return "Cancelled: no work selected."
|
|
485
|
+
|
|
486
|
+
report = BuildReport(mode, project.name, assessment, start_time)
|
|
487
|
+
report.health_before = assessment.health.model_copy()
|
|
488
|
+
_warn_if_baseline_broken(assessment, report)
|
|
489
|
+
_warn_if_no_test_gate(assessment, project, report)
|
|
490
|
+
return self._run_pipeline(
|
|
491
|
+
project,
|
|
492
|
+
goal,
|
|
493
|
+
mode,
|
|
494
|
+
flags,
|
|
495
|
+
assessment,
|
|
496
|
+
env_activate,
|
|
497
|
+
report,
|
|
498
|
+
confirm_plan=True,
|
|
499
|
+
)
|
|
500
|
+
except BudgetExceededError as e:
|
|
501
|
+
return self._halt_on_budget(project, report, e)
|
|
502
|
+
|
|
503
|
+
def _choose_goal(self, recs: list) -> tuple[Optional[str], BuildMode]:
|
|
504
|
+
"""Present recommendations and return the chosen (goal, mode).
|
|
505
|
+
|
|
506
|
+
Returns (None, _) if the user quits. A free-text goal resolves its own
|
|
507
|
+
mode; a picked recommendation carries the advisor's work_type.
|
|
508
|
+
"""
|
|
509
|
+
if recs:
|
|
510
|
+
console.print("\n[bold]Recommended work:[/]")
|
|
511
|
+
for i, r in enumerate(recs, 1):
|
|
512
|
+
console.print(
|
|
513
|
+
f" [cyan]{i}[/]. {r.title} [dim]({r.work_type}) — {r.rationale}[/]"
|
|
514
|
+
)
|
|
515
|
+
console.print("\nEnter a number to pick, type your own goal, or 'q' to quit.")
|
|
516
|
+
choice = Prompt.ask("Goal").strip()
|
|
517
|
+
if not choice or choice.lower() in ("q", "quit"):
|
|
518
|
+
return None, BuildMode.SMART
|
|
519
|
+
if choice.isdigit() and recs:
|
|
520
|
+
idx = int(choice) - 1
|
|
521
|
+
if 0 <= idx < len(recs):
|
|
522
|
+
r = recs[idx]
|
|
523
|
+
return r.title, self._WORK_TYPE_MODES.get(r.work_type, BuildMode.SMART)
|
|
524
|
+
console.print("[yellow]Out of range; treating input as a goal.[/]")
|
|
525
|
+
return choice, resolve_mode(choice, Path("."))
|
|
526
|
+
|
|
527
|
+
_WORK_TYPE_MODES = {
|
|
528
|
+
"debug": BuildMode.DEBUG,
|
|
529
|
+
"complete": BuildMode.COMPLETE,
|
|
530
|
+
"feature": BuildMode.SMART,
|
|
531
|
+
"refactor": BuildMode.SMART,
|
|
532
|
+
"test": BuildMode.SMART,
|
|
533
|
+
"docs": BuildMode.SMART,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
def _confirm(self, question: str) -> bool:
|
|
537
|
+
"""Ask a yes/no question; defaults to no."""
|
|
538
|
+
return Prompt.ask(f"{question} [y/N]", default="n").strip().lower() in (
|
|
539
|
+
"y",
|
|
540
|
+
"yes",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def _working_tree_dirty(self, project: Project) -> str:
|
|
544
|
+
"""Return a short summary if the git working tree has uncommitted changes.
|
|
545
|
+
|
|
546
|
+
Returns "" when clean or not a git repo. `git status --porcelain`
|
|
547
|
+
already excludes ignored paths, so the orchestrator's own `.orchestrator/`
|
|
548
|
+
cache (gitignored) never counts as dirty.
|
|
549
|
+
"""
|
|
550
|
+
if not (Path(project.path) / ".git").exists():
|
|
551
|
+
return ""
|
|
552
|
+
try:
|
|
553
|
+
proc = subprocess.run(
|
|
554
|
+
["git", "status", "--porcelain"],
|
|
555
|
+
cwd=project.path,
|
|
556
|
+
capture_output=True,
|
|
557
|
+
text=True,
|
|
558
|
+
timeout=30,
|
|
559
|
+
)
|
|
560
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
561
|
+
logger.warning(f"Could not check working tree status: {e}")
|
|
562
|
+
return ""
|
|
563
|
+
lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
564
|
+
if not lines:
|
|
565
|
+
return ""
|
|
566
|
+
return f"{len(lines)} file(s), e.g. {lines[0][3:].strip()}"
|
|
567
|
+
|
|
568
|
+
def _run_pipeline(
|
|
569
|
+
self,
|
|
570
|
+
project: Project,
|
|
571
|
+
prompt: str,
|
|
572
|
+
mode: BuildMode,
|
|
573
|
+
flags: BuildFlags,
|
|
574
|
+
assessment: ProjectAssessment,
|
|
575
|
+
env_activate: Optional[str],
|
|
576
|
+
report: BuildReport,
|
|
577
|
+
confirm_plan: bool = False,
|
|
578
|
+
) -> str:
|
|
579
|
+
"""Phases 1.5-6: probes, spec, decompose, (confirm), execute, validate.
|
|
580
|
+
|
|
581
|
+
Shared by build() and interactive_plan(). When confirm_plan is set, the
|
|
582
|
+
composed plan is shown and the user is asked to approve it before any
|
|
583
|
+
task executes.
|
|
584
|
+
"""
|
|
585
|
+
_check_golden_config(project.config)
|
|
586
|
+
# Make the analysis baseline failure count available to the per-task test
|
|
587
|
+
# gate so a RED baseline doesn't reject every task: the gate then accepts a
|
|
588
|
+
# task that leaves the suite no worse, letting a multi-failure project be
|
|
589
|
+
# fixed incrementally. 0 on a green/unknown baseline keeps the gate strict.
|
|
590
|
+
project.baseline_test_failures = int(
|
|
591
|
+
getattr(assessment.health, "test_failures", 0) or 0
|
|
592
|
+
)
|
|
593
|
+
# Also surface the baseline failure OUTPUT so a fix task can see the real
|
|
594
|
+
# failures on its FIRST attempt (instead of editing blind and only learning
|
|
595
|
+
# what broke on retry). Empty on a green baseline.
|
|
596
|
+
project.baseline_test_output = (
|
|
597
|
+
getattr(assessment.health, "test_output", "") or ""
|
|
598
|
+
if project.baseline_test_failures
|
|
599
|
+
else ""
|
|
600
|
+
)
|
|
601
|
+
# Record the pre-build HEAD so the optional goal-completion check can diff
|
|
602
|
+
# the whole build's work (committed task commits + working tree) against
|
|
603
|
+
# it. Best-effort: None outside a git repo or on error, which the check
|
|
604
|
+
# treats as "no diff" and degrades to a summary-only judgment.
|
|
605
|
+
goal_check_base = self._capture_head(project)
|
|
606
|
+
# Spec-as-tests, when enabled, is wired per-task in the executor: the
|
|
607
|
+
# generated failing test is written under .orchestrator/spec_tests/
|
|
608
|
+
# (outside the project suite, so it never flips the integration-gate
|
|
609
|
+
# baseline) and run scoped after the task's own gates pass. Nothing to do
|
|
610
|
+
# at the pipeline level.
|
|
611
|
+
# Sovereign Phase 1.5: Empirical Probes (only for SMART/CREATE modes).
|
|
612
|
+
# Best-effort: probe discovery must never crash the build, so any
|
|
613
|
+
# failure here degrades to no verified facts rather than aborting. Opt out
|
|
614
|
+
# via orchestrator.enable_probes for a cheaper/faster run (probes spend an
|
|
615
|
+
# LLM call plus ephemeral script runs before any task executes).
|
|
616
|
+
verified_facts = ""
|
|
617
|
+
probes_on = get_setting(project.config, "orchestrator", "enable_probes")
|
|
618
|
+
if probes_on and mode in (BuildMode.SMART, BuildMode.CREATE):
|
|
619
|
+
logger.info("Phase 1.5: Empirical Probe Discovery")
|
|
620
|
+
try:
|
|
621
|
+
probe_gen = ProbeGenerator(project.llm_client)
|
|
622
|
+
with EphemeralCodeManager(project.path) as ephemeral:
|
|
623
|
+
probes = probe_gen.generate_probes(prompt, assessment.summary())
|
|
624
|
+
probe_findings = []
|
|
625
|
+
for p in probes:
|
|
626
|
+
success, output = ephemeral.run_ephemeral_script(
|
|
627
|
+
p.get("script", ""),
|
|
628
|
+
name=f"probe_{p.get('name', 'unknown')}",
|
|
629
|
+
)
|
|
630
|
+
probe_findings.append(
|
|
631
|
+
f"Probe: {p.get('name', '?')} -> {output}"
|
|
632
|
+
)
|
|
633
|
+
verified_facts = "\n".join(probe_findings)
|
|
634
|
+
except Exception as e:
|
|
635
|
+
logger.warning(f"Probe discovery failed (non-fatal): {e}")
|
|
636
|
+
report.degraded_subsystems.append(f"Empirical probes: {e}")
|
|
637
|
+
|
|
638
|
+
# Verify completeness claims against the real source before they shape the
|
|
639
|
+
# spec or the task list, so deliberate design (graceful-degradation,
|
|
640
|
+
# platform no-ops, parity shims) is not planned as work. Best-effort and
|
|
641
|
+
# conservative: only claims the verifier refutes with evidence are dropped.
|
|
642
|
+
self._verify_completeness_claims(project, assessment, report)
|
|
643
|
+
|
|
644
|
+
# Phase 2: Generate Spec
|
|
645
|
+
spec = self._generate_spec(
|
|
646
|
+
mode, prompt, assessment, project, facts=verified_facts
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Sovereign enhancements (metacognition, AB-MCTS) are best-effort: they
|
|
650
|
+
# refine the spec but must not crash the build, so each degrades to the
|
|
651
|
+
# current spec on failure rather than aborting before any work is done.
|
|
652
|
+
auditor = SessionAuditor(project.path, project.llm_client)
|
|
653
|
+
try:
|
|
654
|
+
lessons = auditor.get_lessons_context()
|
|
655
|
+
if lessons:
|
|
656
|
+
spec = f"{lessons}\n\n{spec}"
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.warning(f"Lesson injection failed (non-fatal): {e}")
|
|
659
|
+
report.degraded_subsystems.append(f"Lesson injection: {e}")
|
|
660
|
+
|
|
661
|
+
# AB-MCTS branch-and-evaluate is off by default: it fires several serial
|
|
662
|
+
# LLM calls before any work begins (observed ~30 min on one build) for
|
|
663
|
+
# marginal spec refinement. Opt in with orchestrator.enable_ab_mcts.
|
|
664
|
+
if get_setting(project.config, "orchestrator", "enable_ab_mcts"):
|
|
665
|
+
try:
|
|
666
|
+
planner = ABMCTSPlanner(project.llm_client)
|
|
667
|
+
spec = planner.branch_and_evaluate(spec, assessment.summary())
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logger.warning(f"AB-MCTS planning failed (non-fatal): {e}")
|
|
670
|
+
report.degraded_subsystems.append(f"AB-MCTS planning: {e}")
|
|
671
|
+
|
|
672
|
+
# Phase 3: Decompose
|
|
673
|
+
max_tasks = get_setting(project.config, "build", "max_tasks")
|
|
674
|
+
# A --max-tasks flag is a per-run ceiling: the tighter of it and the
|
|
675
|
+
# config cap wins, so a focused/bounded run can't balloon into dozens of
|
|
676
|
+
# tasks (and exhaust the budget) the way a broad spec otherwise would.
|
|
677
|
+
if isinstance(flags.max_tasks, int) and flags.max_tasks > 0:
|
|
678
|
+
max_tasks = min(max_tasks, flags.max_tasks)
|
|
679
|
+
# The decomposer otherwise guesses file paths from feature/test names and
|
|
680
|
+
# the executor then CREATES a wrong new file. Feed it the real file+symbol
|
|
681
|
+
# map so a task targets the actual file that defines the code to change.
|
|
682
|
+
file_map = self._project_file_map(project)
|
|
683
|
+
targets = self._resolve_targets(project)
|
|
684
|
+
tasks = decompose_spec(
|
|
685
|
+
spec,
|
|
686
|
+
assessment,
|
|
687
|
+
mode,
|
|
688
|
+
project.llm_client,
|
|
689
|
+
str(project.path),
|
|
690
|
+
max_tasks=max_tasks,
|
|
691
|
+
file_map=file_map,
|
|
692
|
+
targets=targets,
|
|
693
|
+
)
|
|
694
|
+
tasks = topological_sort(tasks)
|
|
695
|
+
|
|
696
|
+
if flags.dry_run:
|
|
697
|
+
return format_plan(tasks, mode)
|
|
698
|
+
|
|
699
|
+
if confirm_plan:
|
|
700
|
+
console.print(Markdown(format_plan(tasks, mode)))
|
|
701
|
+
if not self._confirm(f"Proceed with these {len(tasks)} tasks?"):
|
|
702
|
+
return "Cancelled: plan not approved."
|
|
703
|
+
|
|
704
|
+
# Phases 4-5: Execute + Gate, wrapped in an outer convergence loop.
|
|
705
|
+
# The loop keeps re-attempting concrete gate failures until the gate is
|
|
706
|
+
# green, the budget runs out, or an iteration makes no progress. The
|
|
707
|
+
# default "auto" is budget-driven: it runs up to CONVERGENCE_CEILING but
|
|
708
|
+
# in practice stops on the budget/no-progress guards below. An explicit
|
|
709
|
+
# positive int caps the iterations hard instead.
|
|
710
|
+
raw_iterations = get_setting(
|
|
711
|
+
project.config, "orchestrator", "max_build_iterations"
|
|
712
|
+
)
|
|
713
|
+
if (
|
|
714
|
+
isinstance(raw_iterations, int)
|
|
715
|
+
and not isinstance(raw_iterations, bool)
|
|
716
|
+
and raw_iterations > 0
|
|
717
|
+
):
|
|
718
|
+
max_build_iterations = raw_iterations
|
|
719
|
+
else:
|
|
720
|
+
max_build_iterations = CONVERGENCE_CEILING
|
|
721
|
+
iteration = 0
|
|
722
|
+
prev_issues: Optional[list[str]] = None
|
|
723
|
+
while True:
|
|
724
|
+
iteration += 1
|
|
725
|
+
tasks_this_iter = len(tasks)
|
|
726
|
+
|
|
727
|
+
# Phase 4: Execution
|
|
728
|
+
self._execute_tasks(tasks, project, flags, report)
|
|
729
|
+
|
|
730
|
+
# Phase 5: Gates
|
|
731
|
+
if flags.no_verify:
|
|
732
|
+
# No gate to converge on; preserve single-pass behavior.
|
|
733
|
+
break
|
|
734
|
+
gatekeeper = GateKeeper(
|
|
735
|
+
project.path,
|
|
736
|
+
env_activate=env_activate,
|
|
737
|
+
build_timeout=get_setting(project.config, "build", "build_timeout"),
|
|
738
|
+
test_timeout=get_setting(project.config, "build", "test_timeout"),
|
|
739
|
+
lint_timeout=get_setting(project.config, "build", "lint_timeout"),
|
|
740
|
+
lsp_diagnostics=get_setting(
|
|
741
|
+
project.config, "orchestrator", "lsp_diagnostics"
|
|
742
|
+
),
|
|
743
|
+
lsp_language=project.config.get("language"),
|
|
744
|
+
lsp_timeout=get_setting(project.config, "orchestrator", "lsp_timeout"),
|
|
745
|
+
container=self._container_engine(project),
|
|
746
|
+
mutation_gate=get_setting(
|
|
747
|
+
project.config, "orchestrator", "mutation_gate"
|
|
748
|
+
),
|
|
749
|
+
mutation_config=project.config.get("mutation") or {},
|
|
750
|
+
runtime_smoke=get_setting(
|
|
751
|
+
project.config, "orchestrator", "runtime_smoke"
|
|
752
|
+
),
|
|
753
|
+
runtime_config=project.config.get("runtime") or {},
|
|
754
|
+
web_verify=get_setting(project.config, "orchestrator", "web_verify"),
|
|
755
|
+
vision_verify=get_setting(
|
|
756
|
+
project.config, "orchestrator", "vision_verify"
|
|
757
|
+
),
|
|
758
|
+
vision_client=project.llm_client,
|
|
759
|
+
)
|
|
760
|
+
commands = {
|
|
761
|
+
"build_command": assessment.structure.build_command,
|
|
762
|
+
"test_command": assessment.structure.test_command,
|
|
763
|
+
"lint_command": assessment.structure.lint_command,
|
|
764
|
+
"golden_command": get_setting(
|
|
765
|
+
project.config, "orchestrator", "golden_command"
|
|
766
|
+
),
|
|
767
|
+
}
|
|
768
|
+
success, issues, final_health = gatekeeper.run_gates(commands)
|
|
769
|
+
validation = ValidationResult()
|
|
770
|
+
validation.build_ok = final_health.builds
|
|
771
|
+
validation.tests_ok = final_health.tests_pass
|
|
772
|
+
validation.lint_ok = final_health.lint_clean
|
|
773
|
+
validation.build_ran = bool(commands["build_command"])
|
|
774
|
+
validation.tests_ran = bool(commands["test_command"])
|
|
775
|
+
validation.lint_ran = bool(commands["lint_command"])
|
|
776
|
+
# Per-target validation: verify EVERY declared sub-project with its
|
|
777
|
+
# own toolchain. A target failure fails the run even if the top-level
|
|
778
|
+
# gate was green (no-op when no targets are declared).
|
|
779
|
+
target_results = self._validate_targets(project, env_activate)
|
|
780
|
+
if target_results:
|
|
781
|
+
summary = ", ".join(
|
|
782
|
+
f"{r['name']}={'OK' if r['ok'] else 'FAIL'}" for r in target_results
|
|
783
|
+
)
|
|
784
|
+
report.key_decisions.append(f"Per-target validation: {summary}")
|
|
785
|
+
failed_targets = [r["name"] for r in target_results if not r["ok"]]
|
|
786
|
+
if failed_targets:
|
|
787
|
+
issues = list(issues) + [
|
|
788
|
+
f"Target validation failed: {', '.join(failed_targets)}"
|
|
789
|
+
]
|
|
790
|
+
validation.issues = issues
|
|
791
|
+
success = False
|
|
792
|
+
report.validation = validation
|
|
793
|
+
report.validation_passed = success
|
|
794
|
+
report.health_after = final_health
|
|
795
|
+
self.last_build_succeeded = success
|
|
796
|
+
if success:
|
|
797
|
+
break
|
|
798
|
+
logger.warning(f"Validation failed: {issues}")
|
|
799
|
+
self._maybe_rollback_regression(project, report, assessment, flags)
|
|
800
|
+
|
|
801
|
+
# Decide whether to attempt another convergence iteration.
|
|
802
|
+
if iteration >= max_build_iterations:
|
|
803
|
+
break
|
|
804
|
+
if _budget_exhausted(project.llm_client):
|
|
805
|
+
logger.warning("Convergence halted: LLM budget exhausted.")
|
|
806
|
+
report.key_decisions.append(
|
|
807
|
+
"Convergence halted: budget exhausted before next iteration"
|
|
808
|
+
)
|
|
809
|
+
break
|
|
810
|
+
# No-progress guards: don't loop on an iteration that ran nothing or
|
|
811
|
+
# produced the identical failure signature.
|
|
812
|
+
if tasks_this_iter == 0:
|
|
813
|
+
break
|
|
814
|
+
if prev_issues is not None and prev_issues == issues:
|
|
815
|
+
report.key_decisions.append(
|
|
816
|
+
f"Convergence halted: iteration {iteration} reproduced the "
|
|
817
|
+
"identical gate failures (no progress)"
|
|
818
|
+
)
|
|
819
|
+
break
|
|
820
|
+
prev_issues = list(issues)
|
|
821
|
+
|
|
822
|
+
# Build a targeted fix spec from the concrete gate failures plus any
|
|
823
|
+
# failed/deferred tasks, re-decompose it, and re-run the same path.
|
|
824
|
+
fix_spec = self._build_fix_spec(report, issues, final_health)
|
|
825
|
+
fix_tasks = decompose_spec(
|
|
826
|
+
fix_spec,
|
|
827
|
+
assessment,
|
|
828
|
+
mode,
|
|
829
|
+
project.llm_client,
|
|
830
|
+
str(project.path),
|
|
831
|
+
max_tasks=max_tasks,
|
|
832
|
+
file_map=file_map,
|
|
833
|
+
targets=targets,
|
|
834
|
+
)
|
|
835
|
+
tasks = topological_sort(fix_tasks)
|
|
836
|
+
report.key_decisions.append(
|
|
837
|
+
f"Convergence iteration {iteration + 1}: re-attempting "
|
|
838
|
+
f"{len(issues)} gate failure(s) with {len(tasks)} fix task(s)"
|
|
839
|
+
)
|
|
840
|
+
if not tasks:
|
|
841
|
+
report.key_decisions.append(
|
|
842
|
+
f"Convergence halted: iteration {iteration + 1} produced no "
|
|
843
|
+
"fix tasks"
|
|
844
|
+
)
|
|
845
|
+
break
|
|
846
|
+
|
|
847
|
+
# Optional goal-completion check (off by default). Runs AFTER the gate
|
|
848
|
+
# loop has settled: an LLM judge reads the goal, acceptance criteria, and
|
|
849
|
+
# the cumulative diff and reports whether the goal is actually met (gates
|
|
850
|
+
# green != goal met). Advisory by default — gaps are recorded and logged
|
|
851
|
+
# but do not fail the build; it blocks only when block_on_goal_gap is set.
|
|
852
|
+
# Timeout-bounded and best-effort, so it can never hang or crash a build.
|
|
853
|
+
if get_setting(project.config, "orchestrator", "goal_check"):
|
|
854
|
+
self._run_goal_check(project, prompt, tasks, goal_check_base, report)
|
|
855
|
+
|
|
856
|
+
# Phase 6: Metacognitive Audit (best-effort; never fail a finished build)
|
|
857
|
+
report.finalize()
|
|
858
|
+
try:
|
|
859
|
+
auditor.audit_session(
|
|
860
|
+
report.completed_tasks, report.failed_tasks, str(report.scratchpad)
|
|
861
|
+
)
|
|
862
|
+
except Exception as e:
|
|
863
|
+
logger.warning(f"Session audit failed (non-fatal): {e}")
|
|
864
|
+
report.degraded_subsystems.append(f"Session audit: {e}")
|
|
865
|
+
|
|
866
|
+
usage = project.llm_client.cumulative_usage
|
|
867
|
+
report.llm_calls = usage.call_count
|
|
868
|
+
report.llm_tokens = usage.total_tokens
|
|
869
|
+
report.llm_prompt_tokens = getattr(usage, "prompt_tokens", 0)
|
|
870
|
+
report.llm_completion_tokens = getattr(usage, "completion_tokens", 0)
|
|
871
|
+
report.llm_cache_read_tokens = getattr(usage, "cache_read_tokens", 0)
|
|
872
|
+
report.llm_cost = usage.estimated_cost
|
|
873
|
+
report.cost_by_task = dict(getattr(project.llm_client, "cost_by_task", {}))
|
|
874
|
+
|
|
875
|
+
report.save(project.path)
|
|
876
|
+
return report.to_markdown()
|
|
877
|
+
|
|
878
|
+
def _capture_head(self, project: Project) -> Optional[str]:
|
|
879
|
+
"""Best-effort current HEAD sha, or None outside a git repo / on error."""
|
|
880
|
+
if not (project.path / ".git").exists():
|
|
881
|
+
return None
|
|
882
|
+
proc = run_git("git rev-parse HEAD", project.path)
|
|
883
|
+
if proc is None:
|
|
884
|
+
return None
|
|
885
|
+
sha = proc.stdout.strip()
|
|
886
|
+
return sha if proc.returncode == 0 and sha else None
|
|
887
|
+
|
|
888
|
+
def _cumulative_diff(self, project: Project, base: Optional[str]) -> str:
|
|
889
|
+
"""Diff of the whole build's work for the goal-check judge.
|
|
890
|
+
|
|
891
|
+
When a pre-build base sha is known, diff ``base`` against the working
|
|
892
|
+
tree (committed task commits + uncommitted changes). Otherwise fall back
|
|
893
|
+
to the working-tree diff vs HEAD. Best-effort: returns "" on any error or
|
|
894
|
+
outside a git repo, which the judge treats as no diff.
|
|
895
|
+
"""
|
|
896
|
+
if not (project.path / ".git").exists():
|
|
897
|
+
return ""
|
|
898
|
+
cmd = f"git diff {base}" if base else "git diff HEAD"
|
|
899
|
+
proc = run_git(cmd, project.path)
|
|
900
|
+
return proc.stdout if proc and proc.returncode == 0 else ""
|
|
901
|
+
|
|
902
|
+
def _run_goal_check(
|
|
903
|
+
self,
|
|
904
|
+
project: Project,
|
|
905
|
+
prompt: str,
|
|
906
|
+
tasks: list,
|
|
907
|
+
base: Optional[str],
|
|
908
|
+
report: BuildReport,
|
|
909
|
+
) -> None:
|
|
910
|
+
"""Run the optional goal-completion check and record its verdict.
|
|
911
|
+
|
|
912
|
+
Advisory by default: a GAP verdict records gaps into the report and logs
|
|
913
|
+
them but does NOT fail the build. It blocks (marks validation failed and
|
|
914
|
+
appends a blocking issue) only when ``orchestrator.block_on_goal_gap`` is
|
|
915
|
+
true. SKIP (no goal/criteria/client, unparseable, timeout, error) is a
|
|
916
|
+
no-op. Wrapped so a judge failure can never crash a finished build.
|
|
917
|
+
"""
|
|
918
|
+
from misterdev.core.verification.goal_check import (
|
|
919
|
+
GAP,
|
|
920
|
+
build_evidence,
|
|
921
|
+
run_goal_check,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
try:
|
|
925
|
+
criteria = "\n".join(
|
|
926
|
+
f"- {t.acceptance_criteria}"
|
|
927
|
+
for t in tasks
|
|
928
|
+
if getattr(t, "acceptance_criteria", "")
|
|
929
|
+
)
|
|
930
|
+
diff = self._cumulative_diff(project, base)
|
|
931
|
+
summary = "; ".join(
|
|
932
|
+
t.title or t.description[:60] for t in report.completed_tasks
|
|
933
|
+
)
|
|
934
|
+
evidence = build_evidence(diff=diff, summary=summary)
|
|
935
|
+
timeout = get_setting(project.config, "orchestrator", "goal_check_timeout")
|
|
936
|
+
judge_model = (project.config.get("judge") or {}).get("model")
|
|
937
|
+
verdict = run_goal_check(
|
|
938
|
+
prompt,
|
|
939
|
+
criteria,
|
|
940
|
+
evidence,
|
|
941
|
+
llm_client=project.llm_client,
|
|
942
|
+
judge_model=judge_model,
|
|
943
|
+
timeout=timeout,
|
|
944
|
+
)
|
|
945
|
+
except Exception as e:
|
|
946
|
+
logger.warning(f"Goal-completion check failed (non-fatal): {e}")
|
|
947
|
+
report.degraded_subsystems.append(f"Goal-completion check: {e}")
|
|
948
|
+
return
|
|
949
|
+
|
|
950
|
+
if verdict.status != GAP:
|
|
951
|
+
logger.info(f"Goal-completion check: {verdict.status} ({verdict.reason})")
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
report.goal_gaps = list(verdict.gaps)
|
|
955
|
+
logger.warning(f"Goal-completion check found {len(verdict.gaps)} gap(s):")
|
|
956
|
+
for gap in verdict.gaps:
|
|
957
|
+
logger.warning(f" goal gap: {gap}")
|
|
958
|
+
|
|
959
|
+
if get_setting(project.config, "orchestrator", "block_on_goal_gap"):
|
|
960
|
+
report.validation_passed = False
|
|
961
|
+
self.last_build_succeeded = False
|
|
962
|
+
issue = "Goal-completion check: " + "; ".join(verdict.gaps)
|
|
963
|
+
if report.validation is not None:
|
|
964
|
+
report.validation.issues.append(issue)
|
|
965
|
+
else:
|
|
966
|
+
report.key_decisions.append(issue)
|
|
967
|
+
|
|
968
|
+
def _build_fix_spec(
|
|
969
|
+
self,
|
|
970
|
+
report: BuildReport,
|
|
971
|
+
issues: list[str],
|
|
972
|
+
final_health: HealthCheck,
|
|
973
|
+
) -> str:
|
|
974
|
+
"""Compose a targeted spec from the gate's concrete failures.
|
|
975
|
+
|
|
976
|
+
Used by the convergence loop for iterations 2+: instead of re-running
|
|
977
|
+
expensive discovery, it points decomposition straight at what the gate
|
|
978
|
+
flagged (build/test/lint output, failed and deferred tasks) so the next
|
|
979
|
+
pass fixes the gap rather than re-planning the whole build.
|
|
980
|
+
"""
|
|
981
|
+
parts = ["# Convergence Fix Spec", "## Goal: make the gate pass\n"]
|
|
982
|
+
if issues:
|
|
983
|
+
parts.append("### Gate Failures")
|
|
984
|
+
for item in issues:
|
|
985
|
+
parts.append(f"- {item}")
|
|
986
|
+
if not final_health.builds and final_health.build_output:
|
|
987
|
+
parts.append(f"\n### Build Output\n{final_health.build_output[:1000]}")
|
|
988
|
+
if not final_health.tests_pass and final_health.test_output:
|
|
989
|
+
parts.append(f"\n### Test Output\n{final_health.test_output[:1000]}")
|
|
990
|
+
if not final_health.lint_clean and final_health.lint_output:
|
|
991
|
+
parts.append(f"\n### Lint Output\n{final_health.lint_output[:1000]}")
|
|
992
|
+
if report.failed_tasks:
|
|
993
|
+
parts.append("\n### Failed Tasks")
|
|
994
|
+
for t in report.failed_tasks:
|
|
995
|
+
parts.append(f"- {t.id}: {t.title}")
|
|
996
|
+
if report.deferred_tasks:
|
|
997
|
+
parts.append("\n### Deferred Tasks")
|
|
998
|
+
for t in report.deferred_tasks:
|
|
999
|
+
parts.append(f"- {t.id}: {t.title}")
|
|
1000
|
+
return "\n".join(parts)
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def _wave_commits(executor, project, tasks) -> list:
|
|
1004
|
+
"""Collect ``(task_id, sha)`` for each task that has a recorded commit,
|
|
1005
|
+
skipping tasks with none. Shared by the regression-revert and
|
|
1006
|
+
integration-gate paths."""
|
|
1007
|
+
commits = []
|
|
1008
|
+
for t in tasks:
|
|
1009
|
+
sha = executor.find_task_commit(project, t.id)
|
|
1010
|
+
if sha:
|
|
1011
|
+
commits.append((t.id, sha))
|
|
1012
|
+
return commits
|
|
1013
|
+
|
|
1014
|
+
def _maybe_rollback_regression(
|
|
1015
|
+
self,
|
|
1016
|
+
project: Project,
|
|
1017
|
+
report: BuildReport,
|
|
1018
|
+
assessment: ProjectAssessment,
|
|
1019
|
+
flags: BuildFlags,
|
|
1020
|
+
) -> None:
|
|
1021
|
+
"""If the post-build gate failed, bisect task commits and revert the culprit."""
|
|
1022
|
+
if flags.no_rollback or not report.completed_tasks:
|
|
1023
|
+
return
|
|
1024
|
+
test_cmd = assessment.structure.test_command
|
|
1025
|
+
if not test_cmd:
|
|
1026
|
+
return
|
|
1027
|
+
ex = MarkdownPlanExecutor()
|
|
1028
|
+
if not ex._is_git_repo(project):
|
|
1029
|
+
return
|
|
1030
|
+
commits = self._wave_commits(ex, project, report.completed_tasks)
|
|
1031
|
+
if not commits:
|
|
1032
|
+
return
|
|
1033
|
+
logger.warning("Post-build regression detected; bisecting task commits...")
|
|
1034
|
+
culprit = ex.bisect_regression(project, commits, test_cmd)
|
|
1035
|
+
if not culprit:
|
|
1036
|
+
logger.info("Bisect did not isolate a single task commit.")
|
|
1037
|
+
return
|
|
1038
|
+
sha = dict(commits)[culprit]
|
|
1039
|
+
if ex.revert_task_commit(project, sha):
|
|
1040
|
+
logger.warning(
|
|
1041
|
+
f"Regression bisected to {culprit}; commit {sha[:8]} reverted."
|
|
1042
|
+
)
|
|
1043
|
+
report.key_decisions.append(
|
|
1044
|
+
f"Regression from {culprit} auto-reverted (bisect)"
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
def _suite_failures(
|
|
1048
|
+
self,
|
|
1049
|
+
project: Project,
|
|
1050
|
+
executor: MarkdownPlanExecutor,
|
|
1051
|
+
test_cmd: str,
|
|
1052
|
+
timeout: int,
|
|
1053
|
+
cwd=None,
|
|
1054
|
+
) -> Optional[int]:
|
|
1055
|
+
"""Full-suite failure count: 0 when green, the parsed count when red, or
|
|
1056
|
+
None when the count can't be parsed (caller then can't count-compare).
|
|
1057
|
+
|
|
1058
|
+
``cwd`` runs the command in a sub-project (target) directory; defaults to
|
|
1059
|
+
the repo root."""
|
|
1060
|
+
from misterdev.core.verification.validator import (
|
|
1061
|
+
_parse_test_counts,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
ok, output = executor._run_command(project, test_cmd, timeout=timeout, cwd=cwd)
|
|
1065
|
+
if ok:
|
|
1066
|
+
return 0
|
|
1067
|
+
total, failures = _parse_test_counts(output)
|
|
1068
|
+
return failures if total > 0 else None
|
|
1069
|
+
|
|
1070
|
+
def _integration_gate_count(
|
|
1071
|
+
self,
|
|
1072
|
+
project: Project,
|
|
1073
|
+
executor: MarkdownPlanExecutor,
|
|
1074
|
+
test_cmd: str,
|
|
1075
|
+
wave_tasks: list[Task],
|
|
1076
|
+
timeout: int,
|
|
1077
|
+
baseline_failures: int,
|
|
1078
|
+
) -> list[str]:
|
|
1079
|
+
"""Count-mode gate for a RED baseline: revert wave commits (newest first)
|
|
1080
|
+
only when the wave RAISED the full-suite failure count above the baseline.
|
|
1081
|
+
|
|
1082
|
+
This closes the gap where, with the binary gate disabled by a red
|
|
1083
|
+
baseline, a task gated on its own scoped tests could worsen the overall
|
|
1084
|
+
suite and still commit. An unparseable post-wave count is left alone (we
|
|
1085
|
+
do not revert on a number we can't read).
|
|
1086
|
+
"""
|
|
1087
|
+
after = self._suite_failures(project, executor, test_cmd, timeout)
|
|
1088
|
+
if after is None or after <= baseline_failures:
|
|
1089
|
+
return []
|
|
1090
|
+
logger.warning(
|
|
1091
|
+
f"Integration gate (count): failures rose {baseline_failures} -> "
|
|
1092
|
+
f"{after}; reverting wave commits until restored."
|
|
1093
|
+
)
|
|
1094
|
+
commits = self._wave_commits(executor, project, wave_tasks)
|
|
1095
|
+
reverted: list[str] = []
|
|
1096
|
+
for tid, sha in reversed(commits):
|
|
1097
|
+
if executor.revert_task_commit(project, sha):
|
|
1098
|
+
reverted.append(tid)
|
|
1099
|
+
now = self._suite_failures(project, executor, test_cmd, timeout)
|
|
1100
|
+
if now is not None and now <= baseline_failures:
|
|
1101
|
+
break
|
|
1102
|
+
return reverted
|
|
1103
|
+
|
|
1104
|
+
@staticmethod
|
|
1105
|
+
def _target_regressed(after: Optional[int], baseline: Optional[int]) -> bool:
|
|
1106
|
+
"""Did a target's gate regress vs its baseline?
|
|
1107
|
+
|
|
1108
|
+
``after``/``baseline`` are :meth:`_suite_failures` results (0 green, N
|
|
1109
|
+
count, None unparseable). A green-now gate never regressed. With no
|
|
1110
|
+
countable baseline we can't compare, so we don't revert. A binary failure
|
|
1111
|
+
now (None) is a regression only if the target was green (baseline 0);
|
|
1112
|
+
otherwise compare counts.
|
|
1113
|
+
"""
|
|
1114
|
+
if after == 0:
|
|
1115
|
+
return False
|
|
1116
|
+
if baseline is None:
|
|
1117
|
+
return False
|
|
1118
|
+
if after is None:
|
|
1119
|
+
return baseline == 0
|
|
1120
|
+
return after > baseline
|
|
1121
|
+
|
|
1122
|
+
def _integration_gate_targets(
|
|
1123
|
+
self,
|
|
1124
|
+
project: Project,
|
|
1125
|
+
executor: MarkdownPlanExecutor,
|
|
1126
|
+
targets: list[dict],
|
|
1127
|
+
wave_tasks: list[Task],
|
|
1128
|
+
timeout: int,
|
|
1129
|
+
target_baselines: dict,
|
|
1130
|
+
) -> list[str]:
|
|
1131
|
+
"""Per-target integration gate: validate each sub-project the wave touched
|
|
1132
|
+
with ITS own toolchain (in ITS directory), reverting only the wave commits
|
|
1133
|
+
belonging to a target that regressed. This is the multi-target analogue of
|
|
1134
|
+
:meth:`_integration_gate` — the last place a polyglot run would otherwise
|
|
1135
|
+
gate with the wrong toolchain.
|
|
1136
|
+
"""
|
|
1137
|
+
from misterdev.core.planning.targets import select_target
|
|
1138
|
+
|
|
1139
|
+
reverted: list[str] = []
|
|
1140
|
+
for tgt in targets:
|
|
1141
|
+
gate_cmd = tgt.get("test_command") or tgt.get("build_command")
|
|
1142
|
+
if not gate_cmd:
|
|
1143
|
+
continue
|
|
1144
|
+
tname = tgt.get("name") or tgt.get("path")
|
|
1145
|
+
tp = (tgt.get("path") or "").strip("/")
|
|
1146
|
+
run_dir = project.path / tp if tp else project.path
|
|
1147
|
+
owned = [
|
|
1148
|
+
t
|
|
1149
|
+
for t in wave_tasks
|
|
1150
|
+
if (
|
|
1151
|
+
select_target(
|
|
1152
|
+
targets, list(t.files_to_modify) + list(t.files_to_create)
|
|
1153
|
+
)
|
|
1154
|
+
or {}
|
|
1155
|
+
).get("path")
|
|
1156
|
+
== tgt.get("path")
|
|
1157
|
+
]
|
|
1158
|
+
if not owned:
|
|
1159
|
+
continue
|
|
1160
|
+
baseline = target_baselines.get(tname)
|
|
1161
|
+
after = self._suite_failures(
|
|
1162
|
+
project, executor, gate_cmd, timeout, cwd=run_dir
|
|
1163
|
+
)
|
|
1164
|
+
if not self._target_regressed(after, baseline):
|
|
1165
|
+
continue
|
|
1166
|
+
logger.warning(
|
|
1167
|
+
f"Integration gate [{tname}]: regressed (baseline={baseline}, "
|
|
1168
|
+
f"after={after}); reverting this target's wave commits."
|
|
1169
|
+
)
|
|
1170
|
+
commits = [(t.id, executor.find_task_commit(project, t.id)) for t in owned]
|
|
1171
|
+
commits = [(tid, sha) for tid, sha in commits if sha]
|
|
1172
|
+
for tid, sha in reversed(commits):
|
|
1173
|
+
if executor.revert_task_commit(project, sha):
|
|
1174
|
+
reverted.append(tid)
|
|
1175
|
+
now = self._suite_failures(
|
|
1176
|
+
project, executor, gate_cmd, timeout, cwd=run_dir
|
|
1177
|
+
)
|
|
1178
|
+
if not self._target_regressed(now, baseline):
|
|
1179
|
+
break
|
|
1180
|
+
return reverted
|
|
1181
|
+
|
|
1182
|
+
def _integration_gate(
|
|
1183
|
+
self,
|
|
1184
|
+
project: Project,
|
|
1185
|
+
executor: MarkdownPlanExecutor,
|
|
1186
|
+
test_cmd: str,
|
|
1187
|
+
wave_tasks: list[Task],
|
|
1188
|
+
timeout: int,
|
|
1189
|
+
baseline_failures: int = 0,
|
|
1190
|
+
) -> list[str]:
|
|
1191
|
+
"""Run the full suite after a wave; revert task commits that regressed it.
|
|
1192
|
+
|
|
1193
|
+
Returns the task_ids whose commits were reverted (empty if the suite
|
|
1194
|
+
still passes). Bisects to the single culprit when possible; if that
|
|
1195
|
+
can't isolate it or the tree is still red afterward, reverts the
|
|
1196
|
+
remaining wave commits (newest first) to restore a green baseline. When
|
|
1197
|
+
``baseline_failures`` > 0 (a red baseline that could still be counted),
|
|
1198
|
+
runs in count mode instead — reverting only a wave that raises the count.
|
|
1199
|
+
"""
|
|
1200
|
+
if baseline_failures > 0:
|
|
1201
|
+
return self._integration_gate_count(
|
|
1202
|
+
project, executor, test_cmd, wave_tasks, timeout, baseline_failures
|
|
1203
|
+
)
|
|
1204
|
+
ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
|
|
1205
|
+
if ok:
|
|
1206
|
+
return []
|
|
1207
|
+
|
|
1208
|
+
commits = self._wave_commits(executor, project, wave_tasks)
|
|
1209
|
+
if not commits:
|
|
1210
|
+
logger.warning(
|
|
1211
|
+
"Integration gate: suite regressed but no task commits found to revert."
|
|
1212
|
+
)
|
|
1213
|
+
return []
|
|
1214
|
+
|
|
1215
|
+
logger.warning("Integration gate: suite regressed; isolating culprit...")
|
|
1216
|
+
reverted: list[str] = []
|
|
1217
|
+
culprit = executor.bisect_regression(
|
|
1218
|
+
project, commits, test_cmd, timeout=timeout
|
|
1219
|
+
)
|
|
1220
|
+
if culprit:
|
|
1221
|
+
sha = dict(commits)[culprit]
|
|
1222
|
+
if executor.revert_task_commit(project, sha):
|
|
1223
|
+
reverted.append(culprit)
|
|
1224
|
+
ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
|
|
1225
|
+
if ok:
|
|
1226
|
+
return reverted
|
|
1227
|
+
|
|
1228
|
+
for tid, sha in reversed(commits):
|
|
1229
|
+
if tid in reverted:
|
|
1230
|
+
continue
|
|
1231
|
+
if executor.revert_task_commit(project, sha):
|
|
1232
|
+
reverted.append(tid)
|
|
1233
|
+
ok, _ = executor._run_command(project, test_cmd, timeout=timeout)
|
|
1234
|
+
if ok:
|
|
1235
|
+
break
|
|
1236
|
+
return reverted
|
|
1237
|
+
|
|
1238
|
+
def _execute_tasks(
|
|
1239
|
+
self,
|
|
1240
|
+
tasks: list[Task],
|
|
1241
|
+
project: Project,
|
|
1242
|
+
flags: BuildFlags,
|
|
1243
|
+
report: BuildReport,
|
|
1244
|
+
) -> None:
|
|
1245
|
+
scratchpad = Scratchpad()
|
|
1246
|
+
aligner = RealTimeAligner(project.path)
|
|
1247
|
+
contracts = ContractRegistry(project.path)
|
|
1248
|
+
progress = ProgressTracker(project.path)
|
|
1249
|
+
changes = ChangeTracker(project.path)
|
|
1250
|
+
strategy_optimizer = StrategyOptimizer()
|
|
1251
|
+
executor = MarkdownPlanExecutor(scratchpad=scratchpad)
|
|
1252
|
+
report.scratchpad = scratchpad
|
|
1253
|
+
|
|
1254
|
+
max_consecutive_failures = get_setting(
|
|
1255
|
+
project.config, "orchestrator", "max_consecutive_failures"
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
completed_ids = set(progress.completed)
|
|
1259
|
+
failed_ids: set[str] = set()
|
|
1260
|
+
consecutive_failures = 0
|
|
1261
|
+
aborted = False
|
|
1262
|
+
max_cost_per_task = get_setting(
|
|
1263
|
+
project.config, "orchestrator", "max_cost_per_task"
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# Register decomposed tasks with the TaskManager so status updates,
|
|
1267
|
+
# progress tracking, and contract lookups resolve by ID (otherwise
|
|
1268
|
+
# build()'s LLM-decomposed task IDs are unknown to the manager).
|
|
1269
|
+
for task in tasks:
|
|
1270
|
+
project.task_manager.tasks.setdefault(task.id, task)
|
|
1271
|
+
|
|
1272
|
+
if completed_ids:
|
|
1273
|
+
logger.info(f"Resuming build: {len(completed_ids)} tasks already completed")
|
|
1274
|
+
|
|
1275
|
+
lang = (project.config.get("language") or "python").lower()
|
|
1276
|
+
remaining = list(tasks)
|
|
1277
|
+
|
|
1278
|
+
# Integration gate: after each wave, re-run the full suite and revert
|
|
1279
|
+
# any task whose merge regressed it. Per-task validation passes a task
|
|
1280
|
+
# in isolation but can't see cross-module breakage that only surfaces
|
|
1281
|
+
# when the whole package is imported together; without this, broken
|
|
1282
|
+
# tasks accumulate under later merges until the end-of-build gate.
|
|
1283
|
+
# Fold the golden suite into the per-wave gate so a task that breaks the
|
|
1284
|
+
# immutable contract is bisected and reverted immediately, not just at
|
|
1285
|
+
# the end-of-iteration GateKeeper.
|
|
1286
|
+
test_cmd = _combine_commands(
|
|
1287
|
+
report.assessment.structure.test_command,
|
|
1288
|
+
get_setting(project.config, "orchestrator", "golden_command"),
|
|
1289
|
+
)
|
|
1290
|
+
test_timeout = get_setting(project.config, "build", "test_timeout")
|
|
1291
|
+
gate_targets = project.config.get("targets") or []
|
|
1292
|
+
gate_active = (
|
|
1293
|
+
not flags.no_rollback
|
|
1294
|
+
and get_setting(project.config, "orchestrator", "integration_gate")
|
|
1295
|
+
and (bool(test_cmd) or bool(gate_targets))
|
|
1296
|
+
and (Path(project.path) / ".git").exists()
|
|
1297
|
+
)
|
|
1298
|
+
# Per-target baselines (multi-target): measure each sub-project's gate in
|
|
1299
|
+
# its own dir ONCE up front, so the per-wave gate reverts only a target
|
|
1300
|
+
# that REGRESSED, and the executor's per-task gate uses the right baseline.
|
|
1301
|
+
target_baselines: dict = {}
|
|
1302
|
+
if gate_active and gate_targets:
|
|
1303
|
+
for tgt in gate_targets:
|
|
1304
|
+
gcmd = tgt.get("test_command") or tgt.get("build_command")
|
|
1305
|
+
if not gcmd:
|
|
1306
|
+
continue
|
|
1307
|
+
tname = tgt.get("name") or tgt.get("path")
|
|
1308
|
+
tp = (tgt.get("path") or "").strip("/")
|
|
1309
|
+
rdir = project.path / tp if tp else project.path
|
|
1310
|
+
target_baselines[tname] = self._suite_failures(
|
|
1311
|
+
project, executor, gcmd, test_timeout, cwd=rdir
|
|
1312
|
+
)
|
|
1313
|
+
project.target_baselines = {
|
|
1314
|
+
k: int(v or 0) for k, v in target_baselines.items()
|
|
1315
|
+
}
|
|
1316
|
+
baseline_failures = 0
|
|
1317
|
+
if gate_active and test_cmd:
|
|
1318
|
+
baseline_ok, baseline_out = executor._run_command(
|
|
1319
|
+
project, test_cmd, timeout=test_timeout
|
|
1320
|
+
)
|
|
1321
|
+
if not baseline_ok:
|
|
1322
|
+
# A red baseline used to fully disable the gate, so a task could
|
|
1323
|
+
# WORSEN the suite (more failures than at the start) and still
|
|
1324
|
+
# commit. Instead, run the gate in COUNT mode against the baseline
|
|
1325
|
+
# failure count when it is parseable — reverting only a wave that
|
|
1326
|
+
# increases failures. Unparseable count -> disable as before.
|
|
1327
|
+
from misterdev.core.verification.validator import (
|
|
1328
|
+
_parse_test_counts,
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
total, fails = _parse_test_counts(baseline_out)
|
|
1332
|
+
if total > 0 and fails > 0:
|
|
1333
|
+
baseline_failures = fails
|
|
1334
|
+
logger.info(
|
|
1335
|
+
f"Integration gate in COUNT mode: baseline has {fails} "
|
|
1336
|
+
"failing test(s); a wave that raises the count is reverted."
|
|
1337
|
+
)
|
|
1338
|
+
else:
|
|
1339
|
+
logger.info(
|
|
1340
|
+
"Integration gate disabled: baseline failing and test "
|
|
1341
|
+
"count unparseable."
|
|
1342
|
+
)
|
|
1343
|
+
gate_active = False
|
|
1344
|
+
|
|
1345
|
+
while remaining and not aborted:
|
|
1346
|
+
# Graceful budget stop: if the global budget is exhausted, do not
|
|
1347
|
+
# launch another wave. Defer the remainder and break so the pipeline
|
|
1348
|
+
# finalizes/reports normally instead of throwing mid-wave.
|
|
1349
|
+
if _budget_exhausted(project.llm_client):
|
|
1350
|
+
logger.warning("Stopping run: LLM budget exhausted.")
|
|
1351
|
+
report.key_decisions.append(
|
|
1352
|
+
"Stopped: budget exhausted; remaining work deferred"
|
|
1353
|
+
)
|
|
1354
|
+
for task in remaining:
|
|
1355
|
+
report.deferred_tasks.append(task)
|
|
1356
|
+
break
|
|
1357
|
+
|
|
1358
|
+
# Find all tasks whose dependencies are satisfied
|
|
1359
|
+
ready = []
|
|
1360
|
+
still_waiting = []
|
|
1361
|
+
for task in remaining:
|
|
1362
|
+
# Skip only if this exact task (id AND content hash) already
|
|
1363
|
+
# completed. Hash-aware so a freshly decomposed plan that reuses
|
|
1364
|
+
# generic ids (T-001...) from a prior build is not wrongly
|
|
1365
|
+
# skipped against stale progress state.
|
|
1366
|
+
if not progress.needs_rerun(
|
|
1367
|
+
task.id, compute_task_hash(task, project.path)
|
|
1368
|
+
):
|
|
1369
|
+
report.completed_tasks.append(task)
|
|
1370
|
+
completed_ids.add(task.id)
|
|
1371
|
+
continue
|
|
1372
|
+
if any(d in failed_ids for d in task.dependencies):
|
|
1373
|
+
report.deferred_tasks.append(task)
|
|
1374
|
+
failed_ids.add(task.id)
|
|
1375
|
+
continue
|
|
1376
|
+
if any(d not in completed_ids for d in task.dependencies):
|
|
1377
|
+
still_waiting.append(task)
|
|
1378
|
+
continue
|
|
1379
|
+
ready.append(task)
|
|
1380
|
+
|
|
1381
|
+
if not ready:
|
|
1382
|
+
for task in still_waiting:
|
|
1383
|
+
report.deferred_tasks.append(task)
|
|
1384
|
+
break
|
|
1385
|
+
|
|
1386
|
+
# Prepare tasks with context
|
|
1387
|
+
for task in ready:
|
|
1388
|
+
strategy = strategy_optimizer.select_best_strategy(
|
|
1389
|
+
task.description,
|
|
1390
|
+
task.category,
|
|
1391
|
+
report.assessment.summary(),
|
|
1392
|
+
project.llm_client,
|
|
1393
|
+
)
|
|
1394
|
+
task.processor_data["strategy"] = strategy
|
|
1395
|
+
task.processor_data["consensus_context"] = (
|
|
1396
|
+
aligner.get_consensus_context()
|
|
1397
|
+
)
|
|
1398
|
+
task.processor_data["interface_contracts"] = (
|
|
1399
|
+
contracts.get_contracts_for_task(task.dependencies)
|
|
1400
|
+
)
|
|
1401
|
+
task.processor_data["recent_changes"] = (
|
|
1402
|
+
changes.get_recent_changes_for_files(
|
|
1403
|
+
task.files_to_modify + task.files_to_create
|
|
1404
|
+
)
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
if flags.interactive:
|
|
1408
|
+
filtered_ready = []
|
|
1409
|
+
for task in ready:
|
|
1410
|
+
action = self._interactive_prompt(
|
|
1411
|
+
task, task.processor_data.get("strategy", "iterative")
|
|
1412
|
+
)
|
|
1413
|
+
if action == "quit":
|
|
1414
|
+
aborted = True
|
|
1415
|
+
break
|
|
1416
|
+
elif action == "skip":
|
|
1417
|
+
report.deferred_tasks.append(task)
|
|
1418
|
+
else:
|
|
1419
|
+
filtered_ready.append(task)
|
|
1420
|
+
ready = filtered_ready
|
|
1421
|
+
if aborted:
|
|
1422
|
+
break
|
|
1423
|
+
|
|
1424
|
+
if not ready:
|
|
1425
|
+
remaining = still_waiting
|
|
1426
|
+
continue
|
|
1427
|
+
|
|
1428
|
+
# Execute: parallel or sequential
|
|
1429
|
+
wave_completed: list[Task] = []
|
|
1430
|
+
if flags.parallel and len(ready) > 1:
|
|
1431
|
+
logger.info(
|
|
1432
|
+
f"Executing {len(ready)} tasks in parallel: {[t.id for t in ready]}"
|
|
1433
|
+
)
|
|
1434
|
+
results = self._execute_parallel(ready, executor, project)
|
|
1435
|
+
else:
|
|
1436
|
+
results = []
|
|
1437
|
+
for task in ready:
|
|
1438
|
+
try:
|
|
1439
|
+
result = executor.execute(task, project)
|
|
1440
|
+
results.append((task, result, None))
|
|
1441
|
+
except Exception as e:
|
|
1442
|
+
results.append((task, None, e))
|
|
1443
|
+
|
|
1444
|
+
# Process results
|
|
1445
|
+
for task, result, error in results:
|
|
1446
|
+
if isinstance(error, BudgetExceededError):
|
|
1447
|
+
# Budget ran out mid-task: revert any partial work and stop
|
|
1448
|
+
# the run gracefully rather than recording a failure.
|
|
1449
|
+
logger.warning(
|
|
1450
|
+
f"Stopping run: budget exhausted during {task.id} ({error})."
|
|
1451
|
+
)
|
|
1452
|
+
sha = executor.find_task_commit(project, task.id)
|
|
1453
|
+
if sha:
|
|
1454
|
+
executor.revert_task_commit(project, sha)
|
|
1455
|
+
report.deferred_tasks.append(task)
|
|
1456
|
+
report.key_decisions.append(
|
|
1457
|
+
"Stopped: budget exhausted; remaining work deferred"
|
|
1458
|
+
)
|
|
1459
|
+
aborted = True
|
|
1460
|
+
break
|
|
1461
|
+
exceeded_fn = getattr(project.llm_client, "task_cost_exceeded", None)
|
|
1462
|
+
if (
|
|
1463
|
+
max_cost_per_task is not None
|
|
1464
|
+
and exceeded_fn
|
|
1465
|
+
and exceeded_fn(task.id)
|
|
1466
|
+
):
|
|
1467
|
+
# This task alone blew its per-task cost cap: abandon and
|
|
1468
|
+
# revert it cleanly instead of burning more budget retrying.
|
|
1469
|
+
cap_fn = getattr(project.llm_client, "effective_task_cap", None)
|
|
1470
|
+
cap = (cap_fn(task.id) if cap_fn else None) or 0.0
|
|
1471
|
+
logger.warning(
|
|
1472
|
+
f"Task {task.id} hit per-task cost cap "
|
|
1473
|
+
f"(${cap:.2f}); abandoning and reverting."
|
|
1474
|
+
)
|
|
1475
|
+
sha = executor.find_task_commit(project, task.id)
|
|
1476
|
+
if sha:
|
|
1477
|
+
executor.revert_task_commit(project, sha)
|
|
1478
|
+
failed_ids.add(task.id)
|
|
1479
|
+
progress.mark_failed(task.id)
|
|
1480
|
+
report.deferred_tasks.append(task)
|
|
1481
|
+
report.key_decisions.append(
|
|
1482
|
+
f"Task {task.id} deferred: exceeded per-task cost cap "
|
|
1483
|
+
f"(${cap:.2f})"
|
|
1484
|
+
)
|
|
1485
|
+
continue
|
|
1486
|
+
if error:
|
|
1487
|
+
logger.error(f"Task {task.id} raised: {error}")
|
|
1488
|
+
failed_ids.add(task.id)
|
|
1489
|
+
progress.mark_failed(task.id)
|
|
1490
|
+
report.failed_tasks.append(task)
|
|
1491
|
+
consecutive_failures += 1
|
|
1492
|
+
elif result.status == "completed":
|
|
1493
|
+
task.execution_history.append(result)
|
|
1494
|
+
completed_ids.add(task.id)
|
|
1495
|
+
progress.mark_completed(
|
|
1496
|
+
task.id, compute_task_hash(task, project.path)
|
|
1497
|
+
)
|
|
1498
|
+
report.completed_tasks.append(task)
|
|
1499
|
+
wave_completed.append(task)
|
|
1500
|
+
consecutive_failures = 0
|
|
1501
|
+
modified = task.files_to_modify + task.files_to_create
|
|
1502
|
+
if modified:
|
|
1503
|
+
contracts.extract_contracts(
|
|
1504
|
+
task.id,
|
|
1505
|
+
modified,
|
|
1506
|
+
project.path,
|
|
1507
|
+
project.llm_client,
|
|
1508
|
+
language=lang,
|
|
1509
|
+
)
|
|
1510
|
+
changes.record_task_changes(task.id, modified)
|
|
1511
|
+
if task.complexity == "architectural":
|
|
1512
|
+
aligner.certify_decision(task.title, task.description)
|
|
1513
|
+
else:
|
|
1514
|
+
task.execution_history.append(result)
|
|
1515
|
+
failed_ids.add(task.id)
|
|
1516
|
+
progress.mark_failed(task.id)
|
|
1517
|
+
report.failed_tasks.append(task)
|
|
1518
|
+
consecutive_failures += 1
|
|
1519
|
+
|
|
1520
|
+
if consecutive_failures >= max_consecutive_failures:
|
|
1521
|
+
aborted = True
|
|
1522
|
+
break
|
|
1523
|
+
|
|
1524
|
+
# Integration gate: after this wave's merges, revert any task that
|
|
1525
|
+
# regressed the full suite before the next wave builds on top of it.
|
|
1526
|
+
if gate_active and wave_completed:
|
|
1527
|
+
if gate_targets:
|
|
1528
|
+
# Polyglot: gate each touched sub-project with its own
|
|
1529
|
+
# toolchain in its own directory (replaces the top-level gate,
|
|
1530
|
+
# which would use the wrong commands for a frontend wave).
|
|
1531
|
+
reverted = self._integration_gate_targets(
|
|
1532
|
+
project,
|
|
1533
|
+
executor,
|
|
1534
|
+
gate_targets,
|
|
1535
|
+
wave_completed,
|
|
1536
|
+
test_timeout,
|
|
1537
|
+
target_baselines,
|
|
1538
|
+
)
|
|
1539
|
+
else:
|
|
1540
|
+
reverted = self._integration_gate(
|
|
1541
|
+
project,
|
|
1542
|
+
executor,
|
|
1543
|
+
test_cmd,
|
|
1544
|
+
wave_completed,
|
|
1545
|
+
test_timeout,
|
|
1546
|
+
baseline_failures=baseline_failures,
|
|
1547
|
+
)
|
|
1548
|
+
for tid in reverted:
|
|
1549
|
+
completed_ids.discard(tid)
|
|
1550
|
+
failed_ids.add(tid)
|
|
1551
|
+
progress.mark_failed(tid)
|
|
1552
|
+
obj = next((t for t in wave_completed if t.id == tid), None)
|
|
1553
|
+
if obj is not None and obj in report.completed_tasks:
|
|
1554
|
+
report.completed_tasks.remove(obj)
|
|
1555
|
+
report.failed_tasks.append(obj)
|
|
1556
|
+
report.key_decisions.append(
|
|
1557
|
+
f"Integration gate: {tid} reverted (regressed full suite)"
|
|
1558
|
+
)
|
|
1559
|
+
consecutive_failures += 1
|
|
1560
|
+
if reverted and consecutive_failures >= max_consecutive_failures:
|
|
1561
|
+
aborted = True
|
|
1562
|
+
|
|
1563
|
+
remaining = still_waiting
|
|
1564
|
+
|
|
1565
|
+
# Defer any unprocessed tasks
|
|
1566
|
+
processed_ids = (
|
|
1567
|
+
completed_ids | failed_ids | {t.id for t in report.deferred_tasks}
|
|
1568
|
+
)
|
|
1569
|
+
for task in tasks:
|
|
1570
|
+
if task.id not in processed_ids:
|
|
1571
|
+
report.deferred_tasks.append(task)
|
|
1572
|
+
|
|
1573
|
+
@staticmethod
|
|
1574
|
+
def _task_file_set(task: Task) -> set:
|
|
1575
|
+
"""Declared files a task will touch (modify + create).
|
|
1576
|
+
|
|
1577
|
+
Tolerates non-list values (e.g. unconfigured mocks): only real lists
|
|
1578
|
+
contribute paths, anything else is treated as "unknown / no claim".
|
|
1579
|
+
"""
|
|
1580
|
+
files: set = set()
|
|
1581
|
+
for attr in ("files_to_modify", "files_to_create"):
|
|
1582
|
+
value = getattr(task, attr, None)
|
|
1583
|
+
if isinstance(value, list):
|
|
1584
|
+
files.update(str(p) for p in value)
|
|
1585
|
+
return files
|
|
1586
|
+
|
|
1587
|
+
@classmethod
|
|
1588
|
+
def _partition_disjoint(cls, ready: list[Task]) -> tuple[list, list]:
|
|
1589
|
+
"""Split tasks into a concurrent-safe group + a serial remainder.
|
|
1590
|
+
|
|
1591
|
+
A task joins the concurrent group only if its declared file set is
|
|
1592
|
+
disjoint from every task already in that group; otherwise it is
|
|
1593
|
+
deferred to the serial remainder so overlapping writes can't interleave.
|
|
1594
|
+
"""
|
|
1595
|
+
concurrent_group: list = []
|
|
1596
|
+
serial_remainder: list = []
|
|
1597
|
+
claimed: set = set()
|
|
1598
|
+
for task in ready:
|
|
1599
|
+
files = cls._task_file_set(task)
|
|
1600
|
+
if files & claimed:
|
|
1601
|
+
serial_remainder.append(task)
|
|
1602
|
+
else:
|
|
1603
|
+
concurrent_group.append(task)
|
|
1604
|
+
claimed |= files
|
|
1605
|
+
return concurrent_group, serial_remainder
|
|
1606
|
+
|
|
1607
|
+
def _execute_parallel(
|
|
1608
|
+
self, ready: list[Task], executor: MarkdownPlanExecutor, project: Project
|
|
1609
|
+
) -> list:
|
|
1610
|
+
"""Execute a batch of independent tasks concurrently.
|
|
1611
|
+
|
|
1612
|
+
In "worktree" mode each task runs in its own git worktree so parallel
|
|
1613
|
+
edits can't collide. When the mode is left at its default and the
|
|
1614
|
+
project is a git repo, worktree isolation is preferred automatically;
|
|
1615
|
+
"shared" must be requested explicitly to opt out. In shared mode only
|
|
1616
|
+
tasks with disjoint declared file sets run in the same concurrent batch;
|
|
1617
|
+
tasks whose file sets overlap are run serially afterwards.
|
|
1618
|
+
"""
|
|
1619
|
+
mode = get_setting(project.config, "orchestrator", "parallel_mode")
|
|
1620
|
+
is_git_repo = (project.path / ".git").exists() is True
|
|
1621
|
+
# "auto" (default) isolates via worktrees on a git repo; the value itself
|
|
1622
|
+
# carries the intent, so no fragile "was it explicitly set" detection.
|
|
1623
|
+
prefer_worktrees = mode == "worktree" or (mode == "auto" and is_git_repo)
|
|
1624
|
+
if prefer_worktrees and is_git_repo:
|
|
1625
|
+
return self._execute_parallel_worktrees(ready, executor, project)
|
|
1626
|
+
|
|
1627
|
+
concurrent_group, serial_remainder = self._partition_disjoint(ready)
|
|
1628
|
+
results = []
|
|
1629
|
+
max_workers = get_setting(project.config, "orchestrator", "max_workers")
|
|
1630
|
+
if concurrent_group:
|
|
1631
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
1632
|
+
max_workers=min(len(concurrent_group), max_workers)
|
|
1633
|
+
) as pool:
|
|
1634
|
+
future_to_task = {
|
|
1635
|
+
pool.submit(
|
|
1636
|
+
executor.execute, task, project, use_git_branch=False
|
|
1637
|
+
): task
|
|
1638
|
+
for task in concurrent_group
|
|
1639
|
+
}
|
|
1640
|
+
for future in concurrent.futures.as_completed(future_to_task):
|
|
1641
|
+
task = future_to_task[future]
|
|
1642
|
+
try:
|
|
1643
|
+
result = future.result()
|
|
1644
|
+
results.append((task, result, None))
|
|
1645
|
+
except Exception as e:
|
|
1646
|
+
results.append((task, None, e))
|
|
1647
|
+
|
|
1648
|
+
# Tasks with overlapping file claims run one at a time.
|
|
1649
|
+
for task in serial_remainder:
|
|
1650
|
+
try:
|
|
1651
|
+
result = executor.execute(task, project, use_git_branch=False)
|
|
1652
|
+
results.append((task, result, None))
|
|
1653
|
+
except Exception as e:
|
|
1654
|
+
results.append((task, None, e))
|
|
1655
|
+
return results
|
|
1656
|
+
|
|
1657
|
+
def _execute_parallel_worktrees(
|
|
1658
|
+
self, ready: list[Task], executor: MarkdownPlanExecutor, project: Project
|
|
1659
|
+
) -> list:
|
|
1660
|
+
"""Run each task in an isolated git worktree, then merge successes back.
|
|
1661
|
+
|
|
1662
|
+
Worktrees are created and merged serially (git's index/worktree metadata
|
|
1663
|
+
is not concurrency-safe); only the task bodies run in parallel.
|
|
1664
|
+
"""
|
|
1665
|
+
import uuid
|
|
1666
|
+
from misterdev.tools.git_tool import GitTool
|
|
1667
|
+
|
|
1668
|
+
git = GitTool({})
|
|
1669
|
+
wt_root = project.path / ".orchestrator" / "worktrees"
|
|
1670
|
+
wt_root.mkdir(parents=True, exist_ok=True)
|
|
1671
|
+
results: list = []
|
|
1672
|
+
prepared: list = []
|
|
1673
|
+
|
|
1674
|
+
for task in ready:
|
|
1675
|
+
branch = f"task/{task.id}"
|
|
1676
|
+
wt_path = wt_root / f"{task.id}-{uuid.uuid4().hex[:6]}"
|
|
1677
|
+
ok, out = git.worktree_add(project, str(wt_path), branch, new_branch=True)
|
|
1678
|
+
if ok:
|
|
1679
|
+
prepared.append((task, wt_path, branch))
|
|
1680
|
+
else:
|
|
1681
|
+
logger.error(f"Worktree add failed for {task.id}: {out}")
|
|
1682
|
+
results.append(
|
|
1683
|
+
(task, None, RuntimeError(f"worktree add failed: {out}"))
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
def run_one(item):
|
|
1687
|
+
task, wt_path, branch = item
|
|
1688
|
+
view = _WorktreeProjectView(project, wt_path)
|
|
1689
|
+
try:
|
|
1690
|
+
return (
|
|
1691
|
+
task,
|
|
1692
|
+
executor.execute(task, view, use_git_branch=False),
|
|
1693
|
+
None,
|
|
1694
|
+
wt_path,
|
|
1695
|
+
branch,
|
|
1696
|
+
)
|
|
1697
|
+
except Exception as e:
|
|
1698
|
+
return (task, None, e, wt_path, branch)
|
|
1699
|
+
|
|
1700
|
+
max_workers = get_setting(project.config, "orchestrator", "max_workers")
|
|
1701
|
+
raw = []
|
|
1702
|
+
if prepared:
|
|
1703
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
1704
|
+
max_workers=min(len(prepared), max_workers)
|
|
1705
|
+
) as pool:
|
|
1706
|
+
futures = [pool.submit(run_one, item) for item in prepared]
|
|
1707
|
+
raw = [f.result() for f in concurrent.futures.as_completed(futures)]
|
|
1708
|
+
|
|
1709
|
+
for task, result, error, wt_path, branch in raw:
|
|
1710
|
+
if result is not None and getattr(result, "status", None) == "completed":
|
|
1711
|
+
merged, mout = git.merge_worktree(project, branch)
|
|
1712
|
+
if not merged:
|
|
1713
|
+
logger.error(f"Worktree merge failed for {task.id}: {mout}")
|
|
1714
|
+
error, result = RuntimeError(f"merge failed: {mout}"), None
|
|
1715
|
+
git.worktree_remove(project, str(wt_path))
|
|
1716
|
+
results.append((task, result, error))
|
|
1717
|
+
return results
|
|
1718
|
+
|
|
1719
|
+
def _interactive_prompt(self, task: Task, strategy: str = "iterative") -> str:
|
|
1720
|
+
console.print(
|
|
1721
|
+
f"\n[bold cyan]Next Task:[/] [{task.id}] {task.title} ([bold magenta]{strategy.upper()}[/])"
|
|
1722
|
+
)
|
|
1723
|
+
choice = Prompt.ask("Proceed?", choices=["y", "n", "s", "q"], default="y")
|
|
1724
|
+
return {"y": "proceed", "q": "quit", "s": "skip", "n": "quit"}[choice]
|
|
1725
|
+
|
|
1726
|
+
def _generate_spec(
|
|
1727
|
+
self,
|
|
1728
|
+
mode: BuildMode,
|
|
1729
|
+
prompt: str,
|
|
1730
|
+
assessment: ProjectAssessment,
|
|
1731
|
+
project: Project,
|
|
1732
|
+
facts: str = "",
|
|
1733
|
+
) -> str:
|
|
1734
|
+
"""Phase 2: Generate a spec based on mode."""
|
|
1735
|
+
if mode == BuildMode.DEBUG:
|
|
1736
|
+
parts = ["# Debug Spec\n## Broken Items"]
|
|
1737
|
+
for item in assessment.features.broken:
|
|
1738
|
+
parts.append(f"- {item}")
|
|
1739
|
+
if assessment.features.stubs:
|
|
1740
|
+
parts.append("\n## Stubs")
|
|
1741
|
+
for item in assessment.features.stubs:
|
|
1742
|
+
parts.append(f"- {item}")
|
|
1743
|
+
if not assessment.health.builds:
|
|
1744
|
+
parts.append(
|
|
1745
|
+
f"\n## Build Failure\n{assessment.health.build_output[:500]}"
|
|
1746
|
+
)
|
|
1747
|
+
if not assessment.health.tests_pass:
|
|
1748
|
+
parts.append(
|
|
1749
|
+
f"\n## Test Failures\n{assessment.health.test_output[:500]}"
|
|
1750
|
+
)
|
|
1751
|
+
return "\n".join(parts)
|
|
1752
|
+
|
|
1753
|
+
if mode == BuildMode.COMPLETE:
|
|
1754
|
+
parts = [
|
|
1755
|
+
f"# Completion Spec\n## Project: {project.name}",
|
|
1756
|
+
"## Goal: Complete all work\n",
|
|
1757
|
+
"### Must Complete",
|
|
1758
|
+
]
|
|
1759
|
+
for f in assessment.features.incomplete:
|
|
1760
|
+
parts.append(f"- {f.name}: {f.description}")
|
|
1761
|
+
parts.append("\n### Must Fix")
|
|
1762
|
+
for item in assessment.features.broken:
|
|
1763
|
+
parts.append(f"- {item}")
|
|
1764
|
+
for item in assessment.features.stubs:
|
|
1765
|
+
parts.append(f"- Stub: {item}")
|
|
1766
|
+
parts.append("\n### Should Add")
|
|
1767
|
+
for f in assessment.features.missing:
|
|
1768
|
+
parts.append(f"- {f.name}: {f.description}")
|
|
1769
|
+
if assessment.features.todos:
|
|
1770
|
+
parts.append(f"\n### TODOs ({len(assessment.features.todos)} items)")
|
|
1771
|
+
for todo in assessment.features.todos[:20]:
|
|
1772
|
+
parts.append(
|
|
1773
|
+
f"- {todo.get('file', '?')}:{todo.get('line', '?')} {todo.get('text', '')}"
|
|
1774
|
+
)
|
|
1775
|
+
return "\n".join(parts)
|
|
1776
|
+
|
|
1777
|
+
if mode == BuildMode.SPEC:
|
|
1778
|
+
spec_path = project.path / prompt.strip()
|
|
1779
|
+
if spec_path.exists():
|
|
1780
|
+
return spec_path.read_text(encoding="utf-8")
|
|
1781
|
+
return f"Spec file not found: {prompt}"
|
|
1782
|
+
|
|
1783
|
+
if mode in (BuildMode.CREATE, BuildMode.SMART):
|
|
1784
|
+
expand_prompt = (
|
|
1785
|
+
f"Expand the following into a comprehensive project spec.\n"
|
|
1786
|
+
f"Include: features with acceptance criteria, error handling, "
|
|
1787
|
+
f"input validation, testing strategy, architecture decisions.\n\n"
|
|
1788
|
+
f"Project context: {assessment.context.purpose}\n"
|
|
1789
|
+
f"Conventions: {assessment.context.conventions}\n"
|
|
1790
|
+
f"Languages: {assessment.structure.languages}\n"
|
|
1791
|
+
f"Frameworks: {assessment.structure.frameworks}\n"
|
|
1792
|
+
f"Existing features: {[f.name for f in assessment.features.existing]}\n"
|
|
1793
|
+
f"Verified facts: {facts}\n\n"
|
|
1794
|
+
f"Description: {prompt}\n\nReturn the spec as markdown."
|
|
1795
|
+
)
|
|
1796
|
+
return project.llm_client.generate_code(
|
|
1797
|
+
expand_prompt,
|
|
1798
|
+
"You are a software architect writing a project specification.",
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
if mode == BuildMode.REVIEW:
|
|
1802
|
+
return (
|
|
1803
|
+
f"# Review Spec\nReview and fix all issues found in the project.\n"
|
|
1804
|
+
f"Broken: {assessment.features.broken}\n"
|
|
1805
|
+
f"Stubs: {assessment.features.stubs}\n"
|
|
1806
|
+
f"TODOs: {len(assessment.features.todos)} items"
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
return f"# Auto Spec\n{prompt}"
|
|
1810
|
+
|
|
1811
|
+
def _setup_env(self, project: Project) -> Optional[str]:
|
|
1812
|
+
"""Initialize the project's env manager and return its activation prefix."""
|
|
1813
|
+
if project.env_manager:
|
|
1814
|
+
project.env_manager.setup()
|
|
1815
|
+
return project.env_manager.activate_command()
|
|
1816
|
+
return None
|
|
1817
|
+
|
|
1818
|
+
def _container_engine(self, project: Project):
|
|
1819
|
+
"""Return the project's container engine if a container environment is
|
|
1820
|
+
configured and an engine is available, else None (gates run locally).
|
|
1821
|
+
|
|
1822
|
+
``_setup_env`` has already called ``setup()``, so the engine is
|
|
1823
|
+
detected by the time gates run.
|
|
1824
|
+
"""
|
|
1825
|
+
from misterdev.environments.container_env import (
|
|
1826
|
+
ContainerEnvironmentManager,
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
env = project.env_manager
|
|
1830
|
+
if isinstance(env, ContainerEnvironmentManager):
|
|
1831
|
+
return env.engine()
|
|
1832
|
+
return None
|
|
1833
|
+
|
|
1834
|
+
def _project_file_map(self, project: Project) -> str:
|
|
1835
|
+
"""The project's real file+symbol outline, for grounding decomposition.
|
|
1836
|
+
|
|
1837
|
+
Best-effort: builds the symbol graph (idempotent) and returns its project
|
|
1838
|
+
outline, or "" if topography is unavailable or errors — the decomposer
|
|
1839
|
+
then falls back to cautious path inference rather than failing.
|
|
1840
|
+
"""
|
|
1841
|
+
topo = getattr(project, "topography", None)
|
|
1842
|
+
if topo is None:
|
|
1843
|
+
return ""
|
|
1844
|
+
try:
|
|
1845
|
+
topo.initialize()
|
|
1846
|
+
return topo.get_project_outline()
|
|
1847
|
+
except Exception as e:
|
|
1848
|
+
logger.warning(f"File map unavailable for decomposition (non-fatal): {e}")
|
|
1849
|
+
return ""
|
|
1850
|
+
|
|
1851
|
+
@staticmethod
|
|
1852
|
+
def _resolve_claim_file(root: Path, label: str, file_map: str) -> Optional[Path]:
|
|
1853
|
+
"""Best-effort map a claim label to a real file for evidence.
|
|
1854
|
+
|
|
1855
|
+
A label that is itself a path wins. Otherwise its DISTINCTIVE identifier
|
|
1856
|
+
tokens (length >= 5, or CamelCase) are matched as WHOLE WORDS against the
|
|
1857
|
+
file+symbol map, longest first, and the first file that mentions one is
|
|
1858
|
+
used. The word-boundary + distinctiveness rules stop a generic token like
|
|
1859
|
+
"backend" from matching an unrelated ``backend_registry.py``. Returns None
|
|
1860
|
+
when nothing resolves — the caller then leaves the claim unverified rather
|
|
1861
|
+
than judging it against the wrong file.
|
|
1862
|
+
"""
|
|
1863
|
+
if label:
|
|
1864
|
+
direct = root / label
|
|
1865
|
+
if direct.is_file():
|
|
1866
|
+
return direct
|
|
1867
|
+
tokens = sorted(
|
|
1868
|
+
{
|
|
1869
|
+
t
|
|
1870
|
+
for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", label or "")
|
|
1871
|
+
if len(t) >= 5 or any(c.isupper() for c in t)
|
|
1872
|
+
},
|
|
1873
|
+
key=len,
|
|
1874
|
+
reverse=True,
|
|
1875
|
+
)
|
|
1876
|
+
for token in tokens:
|
|
1877
|
+
pattern = re.compile(rf"\b{re.escape(token)}\b")
|
|
1878
|
+
for line in file_map.splitlines():
|
|
1879
|
+
if pattern.search(line):
|
|
1880
|
+
cand = root / line.split(":", 1)[0].strip()
|
|
1881
|
+
if cand.is_file():
|
|
1882
|
+
return cand
|
|
1883
|
+
return None
|
|
1884
|
+
|
|
1885
|
+
def _verify_completeness_claims(
|
|
1886
|
+
self, project: Project, assessment: ProjectAssessment, report: BuildReport
|
|
1887
|
+
) -> None:
|
|
1888
|
+
"""Drop completeness claims a second component refutes against the source.
|
|
1889
|
+
|
|
1890
|
+
The analyzer flags "incomplete"/"stub" items from a lossy overview, so it
|
|
1891
|
+
can mislabel deliberate design (graceful-degradation, platform no-ops,
|
|
1892
|
+
parity shims) as work. Before the spec and tasks are built, recheck each
|
|
1893
|
+
claim against the REAL file plus the verified build/test state with an
|
|
1894
|
+
independent verifier and prune only the ones it refutes WITH evidence;
|
|
1895
|
+
unsure / skip / timeout keeps the claim, so genuine work is never lost.
|
|
1896
|
+
No-op when disabled or when no LLM verifier is available.
|
|
1897
|
+
"""
|
|
1898
|
+
if not get_setting(project.config, "orchestrator", "verify_claims"):
|
|
1899
|
+
return
|
|
1900
|
+
feats = assessment.features
|
|
1901
|
+
if not feats.incomplete and not feats.stubs:
|
|
1902
|
+
logger.info(
|
|
1903
|
+
"Completeness-claim verifier: no incomplete/stub claims flagged; "
|
|
1904
|
+
"nothing to verify."
|
|
1905
|
+
)
|
|
1906
|
+
return
|
|
1907
|
+
|
|
1908
|
+
from misterdev.analyzers.project_analyzer import (
|
|
1909
|
+
_health_ground_truth,
|
|
1910
|
+
)
|
|
1911
|
+
from misterdev.core.verification.claim_verifier import (
|
|
1912
|
+
Claim,
|
|
1913
|
+
verify_claims,
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
root = project.path
|
|
1917
|
+
health = _health_ground_truth(assessment.health)
|
|
1918
|
+
file_map = self._project_file_map(project)
|
|
1919
|
+
|
|
1920
|
+
def read_body(path: Optional[Path]) -> str:
|
|
1921
|
+
if path is None or not path.is_file():
|
|
1922
|
+
return ""
|
|
1923
|
+
try:
|
|
1924
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
1925
|
+
except OSError:
|
|
1926
|
+
return ""
|
|
1927
|
+
|
|
1928
|
+
# Build a claim ONLY when the claimed file's real source is in hand. Without
|
|
1929
|
+
# it the verifier would see just the "build passes" line — which the prompt
|
|
1930
|
+
# treats as grounds to refute — and could drop a genuine claim on no
|
|
1931
|
+
# evidence, so an unresolved claim is KEPT, unverified. `health` goes FIRST
|
|
1932
|
+
# so it survives the verifier's evidence truncation. `origin` is the
|
|
1933
|
+
# assessment object/string, so pruning is by identity and a shared or empty
|
|
1934
|
+
# label can't drop the wrong claim.
|
|
1935
|
+
entries = [] # (Claim, kind, origin)
|
|
1936
|
+
unverified = 0
|
|
1937
|
+
for fi in feats.incomplete:
|
|
1938
|
+
path = self._resolve_claim_file(root, fi.name, file_map)
|
|
1939
|
+
body = read_body(path)
|
|
1940
|
+
if not body:
|
|
1941
|
+
unverified += 1
|
|
1942
|
+
continue
|
|
1943
|
+
try:
|
|
1944
|
+
rel = path.relative_to(root)
|
|
1945
|
+
except ValueError:
|
|
1946
|
+
rel = path
|
|
1947
|
+
entries.append(
|
|
1948
|
+
(
|
|
1949
|
+
Claim(
|
|
1950
|
+
kind="incomplete",
|
|
1951
|
+
label=fi.name,
|
|
1952
|
+
description=f"{fi.description} (source file: {rel})",
|
|
1953
|
+
evidence=f"{health}\n\n{body}",
|
|
1954
|
+
),
|
|
1955
|
+
"incomplete",
|
|
1956
|
+
fi,
|
|
1957
|
+
)
|
|
1958
|
+
)
|
|
1959
|
+
for sp in feats.stubs:
|
|
1960
|
+
if not isinstance(sp, str):
|
|
1961
|
+
continue
|
|
1962
|
+
body = read_body(root / sp)
|
|
1963
|
+
if not body:
|
|
1964
|
+
unverified += 1
|
|
1965
|
+
continue
|
|
1966
|
+
entries.append(
|
|
1967
|
+
(
|
|
1968
|
+
Claim(
|
|
1969
|
+
kind="stub",
|
|
1970
|
+
label=sp,
|
|
1971
|
+
description=f"flagged as a stub file (source file: {sp})",
|
|
1972
|
+
evidence=f"{health}\n\n{body}",
|
|
1973
|
+
),
|
|
1974
|
+
"stub",
|
|
1975
|
+
sp,
|
|
1976
|
+
)
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
if not entries:
|
|
1980
|
+
logger.info(
|
|
1981
|
+
"Completeness-claim verifier: no claims with readable source to "
|
|
1982
|
+
f"verify ({unverified} kept unverified)."
|
|
1983
|
+
)
|
|
1984
|
+
return
|
|
1985
|
+
|
|
1986
|
+
judge_model = (project.config.get("judge") or {}).get("model")
|
|
1987
|
+
timeout = get_setting(project.config, "orchestrator", "verify_claims_timeout")
|
|
1988
|
+
logger.info(
|
|
1989
|
+
f"Verifying {len(entries)} completeness claim(s) against the real source..."
|
|
1990
|
+
)
|
|
1991
|
+
try:
|
|
1992
|
+
verdicts = verify_claims(
|
|
1993
|
+
[claim for claim, _, _ in entries],
|
|
1994
|
+
llm_client=project.llm_client,
|
|
1995
|
+
model=judge_model,
|
|
1996
|
+
timeout=timeout,
|
|
1997
|
+
)
|
|
1998
|
+
except Exception as e: # the gate must never crash a build
|
|
1999
|
+
logger.warning(f"Completeness-claim verification failed (non-fatal): {e}")
|
|
2000
|
+
report.degraded_subsystems.append(f"Claim verifier: {e}")
|
|
2001
|
+
return
|
|
2002
|
+
|
|
2003
|
+
drop_incomplete: set[int] = set()
|
|
2004
|
+
drop_stubs: set[str] = set()
|
|
2005
|
+
for (claim, kind, origin), v in zip(entries, verdicts):
|
|
2006
|
+
if not v.refuted:
|
|
2007
|
+
continue
|
|
2008
|
+
if kind == "incomplete":
|
|
2009
|
+
drop_incomplete.add(id(origin))
|
|
2010
|
+
else:
|
|
2011
|
+
drop_stubs.add(origin)
|
|
2012
|
+
msg = f"Dropped phantom completeness claim '{claim.label}': {v.reason}"
|
|
2013
|
+
logger.info(msg)
|
|
2014
|
+
report.key_decisions.append(msg)
|
|
2015
|
+
|
|
2016
|
+
dropped = len(drop_incomplete) + len(drop_stubs)
|
|
2017
|
+
logger.info(
|
|
2018
|
+
f"Completeness-claim verification: {len(entries) - dropped} kept, "
|
|
2019
|
+
f"{dropped} dropped ({unverified} unverified)."
|
|
2020
|
+
)
|
|
2021
|
+
if dropped:
|
|
2022
|
+
feats.incomplete = [
|
|
2023
|
+
fi for fi in feats.incomplete if id(fi) not in drop_incomplete
|
|
2024
|
+
]
|
|
2025
|
+
feats.stubs = [sp for sp in feats.stubs if sp not in drop_stubs]
|
|
2026
|
+
|
|
2027
|
+
def _resolve_targets(self, project: Project) -> list[dict]:
|
|
2028
|
+
"""Explicit ``targets`` if declared, else auto-discovered when enabled.
|
|
2029
|
+
|
|
2030
|
+
Discovered targets are written back into ``project.config['targets']`` so
|
|
2031
|
+
the executor's per-task routing and per-target validation see them too.
|
|
2032
|
+
"""
|
|
2033
|
+
explicit = project.config.get("targets") or []
|
|
2034
|
+
if explicit:
|
|
2035
|
+
return explicit
|
|
2036
|
+
if not get_setting(project.config, "orchestrator", "auto_targets"):
|
|
2037
|
+
return []
|
|
2038
|
+
from misterdev.core.planning.targets import discover_targets
|
|
2039
|
+
|
|
2040
|
+
discovered = discover_targets(str(project.path))
|
|
2041
|
+
if discovered:
|
|
2042
|
+
names = ", ".join(t["name"] for t in discovered)
|
|
2043
|
+
logger.info(
|
|
2044
|
+
f"Auto-discovered {len(discovered)} polyglot target(s): {names}"
|
|
2045
|
+
)
|
|
2046
|
+
project.config["targets"] = discovered
|
|
2047
|
+
return discovered
|
|
2048
|
+
|
|
2049
|
+
def _validate_targets(
|
|
2050
|
+
self, project: Project, env_activate: Optional[str]
|
|
2051
|
+
) -> list[dict]:
|
|
2052
|
+
"""Validate each declared target with its OWN toolchain, vs its baseline.
|
|
2053
|
+
|
|
2054
|
+
Closes the multi-target gap where the end-of-run GateKeeper only ran the
|
|
2055
|
+
top-level commands. Crucially this compares against each target's baseline
|
|
2056
|
+
(measured before the run, stored on ``project.target_baselines``), so a
|
|
2057
|
+
target that was ALREADY broken (e.g. a frontend with pre-existing errors)
|
|
2058
|
+
is not counted as a failure for a run that never touched it — only a
|
|
2059
|
+
genuine REGRESSION fails. Returns [] when no targets are declared, so
|
|
2060
|
+
single-target builds are unaffected.
|
|
2061
|
+
"""
|
|
2062
|
+
targets = project.config.get("targets") or []
|
|
2063
|
+
if not targets:
|
|
2064
|
+
return []
|
|
2065
|
+
# getattr seam lets tests inject a fake runner; prod creates a real one.
|
|
2066
|
+
executor = getattr(self, "_validate_executor", None) or MarkdownPlanExecutor()
|
|
2067
|
+
target_baselines = getattr(project, "target_baselines", {}) or {}
|
|
2068
|
+
build_to = get_setting(project.config, "build", "build_timeout")
|
|
2069
|
+
test_to = get_setting(project.config, "build", "test_timeout")
|
|
2070
|
+
results: list[dict] = []
|
|
2071
|
+
for t in targets:
|
|
2072
|
+
gate_cmd = t.get("test_command") or t.get("build_command")
|
|
2073
|
+
if not gate_cmd:
|
|
2074
|
+
continue
|
|
2075
|
+
name = t.get("name") or t.get("path") or "?"
|
|
2076
|
+
tp = (t.get("path") or "").strip("/")
|
|
2077
|
+
run_dir = project.path / tp if tp else project.path
|
|
2078
|
+
timeout = test_to if t.get("test_command") else build_to
|
|
2079
|
+
after = self._suite_failures(
|
|
2080
|
+
project, executor, gate_cmd, timeout, cwd=run_dir
|
|
2081
|
+
)
|
|
2082
|
+
baseline = target_baselines.get(name)
|
|
2083
|
+
regressed = self._target_regressed(after, baseline)
|
|
2084
|
+
ok = not regressed
|
|
2085
|
+
detail = "ok" if ok else f"regressed (baseline={baseline}, after={after})"
|
|
2086
|
+
# Behavioral verification (opt-in): a frontend target may declare a
|
|
2087
|
+
# `web`/`vision` block to verify it actually RENDERS/works, not just
|
|
2088
|
+
# type-checks. Only run when the build/test gate is already clean.
|
|
2089
|
+
if ok and (t.get("web") or t.get("vision")):
|
|
2090
|
+
ok, rt_detail = self._run_target_runtime_gates(project, t, run_dir)
|
|
2091
|
+
if not ok:
|
|
2092
|
+
detail = rt_detail
|
|
2093
|
+
results.append({"name": name, "ok": ok, "detail": detail})
|
|
2094
|
+
return results
|
|
2095
|
+
|
|
2096
|
+
def _run_target_runtime_gates(
|
|
2097
|
+
self, project: Project, target: dict, run_dir
|
|
2098
|
+
) -> tuple[bool, str]:
|
|
2099
|
+
"""Run a target's opt-in web/vision behavioral gates in its directory.
|
|
2100
|
+
|
|
2101
|
+
Mirrors the GateKeeper's G4.7/G4.8 but scoped to a sub-project: the web
|
|
2102
|
+
gate renders + screenshots, the vision gate judges that screenshot. Both
|
|
2103
|
+
are best-effort and timeout-bounded — only a RED (a real failed check)
|
|
2104
|
+
fails the target; a SKIP (no browser/model/config) passes.
|
|
2105
|
+
"""
|
|
2106
|
+
evidence = None
|
|
2107
|
+
web_cfg = target.get("web")
|
|
2108
|
+
if web_cfg:
|
|
2109
|
+
from misterdev.core.verification.web_verify import (
|
|
2110
|
+
run_web_gate,
|
|
2111
|
+
)
|
|
2112
|
+
|
|
2113
|
+
web = run_web_gate(run_dir, web_cfg)
|
|
2114
|
+
evidence = getattr(web, "evidence", None)
|
|
2115
|
+
if web.status == "red":
|
|
2116
|
+
return False, f"web verify failed ({web.reason or 'no detail'})"
|
|
2117
|
+
vision_cfg = target.get("vision")
|
|
2118
|
+
if vision_cfg:
|
|
2119
|
+
from misterdev.core.verification.vision_verify import (
|
|
2120
|
+
run_vision_gate,
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
vc = dict(vision_cfg)
|
|
2124
|
+
if not vc.get("capture") and evidence:
|
|
2125
|
+
vc["capture"] = evidence
|
|
2126
|
+
vision = run_vision_gate(
|
|
2127
|
+
run_dir, vc or None, llm_client=getattr(project, "llm_client", None)
|
|
2128
|
+
)
|
|
2129
|
+
if vision.status == "red":
|
|
2130
|
+
return False, f"vision verify failed ({vision.reason or 'no detail'})"
|
|
2131
|
+
return True, "ok"
|
|
2132
|
+
|
|
2133
|
+
def _analyze(self, project: Project, env_activate: Optional[str]):
|
|
2134
|
+
"""Phase 1 analysis with config-driven commands and timeouts.
|
|
2135
|
+
|
|
2136
|
+
Shared by build() and interactive_plan() so the analyzer's parameters
|
|
2137
|
+
(and any future config wiring) live in exactly one place.
|
|
2138
|
+
"""
|
|
2139
|
+
# Build the project's symbol graph ONCE via its TopographyEngine and feed
|
|
2140
|
+
# the outline to the analyzer, instead of letting the source overview parse
|
|
2141
|
+
# a second throwaway graph. The engine's initialize() is idempotent, so the
|
|
2142
|
+
# later decomposition/file-map calls reuse this same graph.
|
|
2143
|
+
project_outline = self._project_file_map(project) or None
|
|
2144
|
+
return analyze_project(
|
|
2145
|
+
project.path,
|
|
2146
|
+
project.llm_client,
|
|
2147
|
+
build_command=project.config.get("build_command"),
|
|
2148
|
+
test_command=project.config.get("test_command"),
|
|
2149
|
+
lint_command=project.config.get("lint_command"),
|
|
2150
|
+
env_activate=env_activate,
|
|
2151
|
+
build_timeout=get_setting(project.config, "build", "build_timeout"),
|
|
2152
|
+
test_timeout=get_setting(project.config, "build", "test_timeout"),
|
|
2153
|
+
lint_timeout=get_setting(project.config, "build", "lint_timeout"),
|
|
2154
|
+
parallel=get_setting(project.config, "build", "parallel_analysis"),
|
|
2155
|
+
project_outline=project_outline,
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
def _get_or_register(self, project_path: str | Path) -> Optional[Project]:
|
|
2159
|
+
project = self.registry.get_project(project_path)
|
|
2160
|
+
if not project:
|
|
2161
|
+
try:
|
|
2162
|
+
project = self.registry.register_project(project_path)
|
|
2163
|
+
except Exception as e:
|
|
2164
|
+
logger.error(f"Failed to register project at {project_path}: {e}")
|
|
2165
|
+
return None
|
|
2166
|
+
return project
|