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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Cost-aware, data-driven model selection.
|
|
2
|
+
|
|
3
|
+
Turns the static complexity->tier->model routing into a policy that picks a
|
|
4
|
+
model to maximize quality-per-dollar under an unconditional quality floor: the
|
|
5
|
+
validation gates. A cheaper model that writes worse code simply fails the gate
|
|
6
|
+
and the policy escalates, so model choice can only change cost/latency, never
|
|
7
|
+
shipped quality.
|
|
8
|
+
|
|
9
|
+
The policy is deterministic (UCB scoring from the ledger, not random draws) and
|
|
10
|
+
disabled by default; with it off, ``select`` returns None and the executor
|
|
11
|
+
keeps its existing static routing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from misterdev.config import get_setting
|
|
17
|
+
from misterdev.core.economics.model_ledger import ModelLedger
|
|
18
|
+
from misterdev.logging_setup import setup_logger
|
|
19
|
+
|
|
20
|
+
logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ModelSelector:
|
|
24
|
+
"""Picks a model id per attempt from the escalation ladder and ledger."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, config: dict, ledger: ModelLedger, free_models: Optional[List[str]] = None
|
|
28
|
+
):
|
|
29
|
+
self.ledger = ledger
|
|
30
|
+
# dynamic_selection is False (off), True (on), or "auto" (self-
|
|
31
|
+
# activating). A non-"auto" string is treated as off, never truthy.
|
|
32
|
+
raw = get_setting(config, "llm", "dynamic_selection")
|
|
33
|
+
if isinstance(raw, str):
|
|
34
|
+
self.auto = raw.strip().lower() == "auto"
|
|
35
|
+
self.enabled = self.auto
|
|
36
|
+
else:
|
|
37
|
+
self.auto = False
|
|
38
|
+
self.enabled = bool(raw)
|
|
39
|
+
self.escalation: List[str] = list(
|
|
40
|
+
get_setting(config, "llm", "escalation") or []
|
|
41
|
+
)
|
|
42
|
+
self.models = dict(get_setting(config, "llm", "models") or {})
|
|
43
|
+
self.min_obs = get_setting(config, "llm", "min_observations")
|
|
44
|
+
self.first_try_floor = get_setting(config, "llm", "first_try_floor")
|
|
45
|
+
self.incompetence_floor = get_setting(config, "llm", "incompetence_floor")
|
|
46
|
+
self.max_latency = get_setting(config, "llm", "max_attempt_latency_seconds")
|
|
47
|
+
self.posture = get_setting(config, "llm", "selection_posture")
|
|
48
|
+
self.maturity_threshold = get_setting(config, "llm", "maturity_threshold")
|
|
49
|
+
# Cells already announced as graduated to cheap-first (one log per cell).
|
|
50
|
+
self._graduated: set = set()
|
|
51
|
+
free = list(free_models or [])
|
|
52
|
+
if self.escalation and free:
|
|
53
|
+
# Harvested free models join the configured cheapest tier as extra
|
|
54
|
+
# candidates, so they are only ever tried on early (non-final)
|
|
55
|
+
# attempts and under the same proven-first-try gate as any cheap
|
|
56
|
+
# model. They go first so an exploration tie among unseen candidates
|
|
57
|
+
# (equal +inf UCB) breaks toward the zero-cost option.
|
|
58
|
+
cheapest = self.escalation[0]
|
|
59
|
+
existing = self._tier_models(cheapest)
|
|
60
|
+
self.models[cheapest] = [m for m in free if m not in existing] + existing
|
|
61
|
+
elif not self.escalation and free:
|
|
62
|
+
# No ladder configured: self-assemble one from the harvested free
|
|
63
|
+
# models (cheap) and the default model (strong). This is what makes
|
|
64
|
+
# free models work out of the box with no escalation/models config.
|
|
65
|
+
default_model = get_setting(config, "llm", "model")
|
|
66
|
+
self.models = {**self.models, "_free": free}
|
|
67
|
+
if default_model:
|
|
68
|
+
self.models["_default"] = [default_model]
|
|
69
|
+
self.escalation = ["_free", "_default"]
|
|
70
|
+
|
|
71
|
+
def _tier_models(self, tier: str) -> List[str]:
|
|
72
|
+
v = self.models.get(tier)
|
|
73
|
+
if v is None:
|
|
74
|
+
return []
|
|
75
|
+
if isinstance(v, str):
|
|
76
|
+
return [v]
|
|
77
|
+
return [m for m in v if isinstance(m, str)]
|
|
78
|
+
|
|
79
|
+
def _rungs(self) -> List[str]:
|
|
80
|
+
"""Escalation tiers that actually resolve to at least one model."""
|
|
81
|
+
return [t for t in self.escalation if self._tier_models(t)]
|
|
82
|
+
|
|
83
|
+
def _cost(self, model: str, category: str, complexity: str) -> float:
|
|
84
|
+
"""Mean successful-attempt cost, or a neutral 1.0 when unknown.
|
|
85
|
+
|
|
86
|
+
Unknown cost stays neutral so an unseen model is ranked purely on its
|
|
87
|
+
(optimistic) quality score rather than being favored for a $0 estimate.
|
|
88
|
+
"""
|
|
89
|
+
avg = self.ledger.stat(model, category, complexity).avg_cost
|
|
90
|
+
return avg if avg > 0 else 1.0
|
|
91
|
+
|
|
92
|
+
# Empirical-Bayes prior weight: how many pseudo-observations of the model's
|
|
93
|
+
# GLOBAL first-try rate to fold into a cold cell. Small, so cell-specific data
|
|
94
|
+
# overrides it within a few real attempts — the prior only warm-starts.
|
|
95
|
+
_PRIOR_WEIGHT = 3.0
|
|
96
|
+
|
|
97
|
+
def _proven(self, model: str, category: str, complexity: str) -> bool:
|
|
98
|
+
"""True when a model has earned trust for a first attempt on this cell.
|
|
99
|
+
|
|
100
|
+
A cold cell borrows strength from the model's GLOBAL first-try rate
|
|
101
|
+
(shrinkage): a model reliable everywhere else clears the floor here with
|
|
102
|
+
fewer local observations, while a globally-weak model does not. As the
|
|
103
|
+
cell accumulates real attempts, the blended rate converges to the cell's
|
|
104
|
+
own — the prior fades. A model with no history anywhere stays unproven.
|
|
105
|
+
"""
|
|
106
|
+
s = self.ledger.stat(model, category, complexity)
|
|
107
|
+
g_att, g_rate = self.ledger.global_first_try(model, category, complexity)
|
|
108
|
+
prior = self._PRIOR_WEIGHT if g_att >= self.min_obs else 0.0
|
|
109
|
+
effective_obs = s.first_try_attempts + prior
|
|
110
|
+
if effective_obs < self.min_obs:
|
|
111
|
+
return False
|
|
112
|
+
blended = (s.first_try_successes + prior * g_rate) / effective_obs
|
|
113
|
+
return blended >= self.first_try_floor
|
|
114
|
+
|
|
115
|
+
def _incompetent(self, model: str, category: str, complexity: str) -> bool:
|
|
116
|
+
"""Proven UNABLE to do this kind of task: enough attempts to judge and a
|
|
117
|
+
success rate below the floor. Distinct from unproven (too few attempts).
|
|
118
|
+
Trying such a model only burns a failed attempt and forces escalation."""
|
|
119
|
+
if not self.incompetence_floor:
|
|
120
|
+
return False
|
|
121
|
+
s = self.ledger.stat(model, category, complexity)
|
|
122
|
+
return s.attempts >= self.min_obs and s.success_rate < self.incompetence_floor
|
|
123
|
+
|
|
124
|
+
def _too_slow(self, model: str, category: str, complexity: str) -> bool:
|
|
125
|
+
"""Proven too slow for the per-task wall-clock budget (ledger avg latency
|
|
126
|
+
above the configured ceiling). Unseen models (avg 0) are never flagged."""
|
|
127
|
+
if not self.max_latency:
|
|
128
|
+
return False
|
|
129
|
+
return (
|
|
130
|
+
self.ledger.stat(model, category, complexity).avg_latency > self.max_latency
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Free (`:free`) endpoints are reserved for the easiest tasks: they are slow
|
|
134
|
+
# (2.5-5 min/call observed) and unreliable, so on anything heavier than a
|
|
135
|
+
# small task they cost more wall-clock and failed attempts than they save.
|
|
136
|
+
_FREE_OK_COMPLEXITY = ("trivial", "small")
|
|
137
|
+
|
|
138
|
+
def _pick(
|
|
139
|
+
self,
|
|
140
|
+
models: List[str],
|
|
141
|
+
category: str,
|
|
142
|
+
complexity: str,
|
|
143
|
+
explore: bool,
|
|
144
|
+
final: bool = False,
|
|
145
|
+
) -> Optional[str]:
|
|
146
|
+
"""Best model by quality-per-dollar.
|
|
147
|
+
|
|
148
|
+
explore=True uses the optimistic UCB score (unseen models score +inf, so
|
|
149
|
+
they get tried); explore=False uses the conservative Wilson lower bound
|
|
150
|
+
(unseen models score 0, so only proven models win). Free endpoints are
|
|
151
|
+
dropped for non-easy tasks (see ``_FREE_OK_COMPLEXITY``), and models the
|
|
152
|
+
ledger has proven incompetent (or, on non-final attempts, too slow) are
|
|
153
|
+
dropped; if that empties the tier, returns None so the caller climbs to
|
|
154
|
+
the next (paid) rung. Incompetence still filters on the final attempt — a
|
|
155
|
+
model that cannot do the task is never the right last resort.
|
|
156
|
+
"""
|
|
157
|
+
if complexity not in self._FREE_OK_COMPLEXITY:
|
|
158
|
+
models = [m for m in models if ":free" not in m]
|
|
159
|
+
models = [m for m in models if not self._incompetent(m, category, complexity)]
|
|
160
|
+
if not final:
|
|
161
|
+
models = [m for m in models if not self._too_slow(m, category, complexity)]
|
|
162
|
+
if not models:
|
|
163
|
+
return None
|
|
164
|
+
total = self.ledger.total_observations(category, complexity)
|
|
165
|
+
|
|
166
|
+
def value(model: str) -> float:
|
|
167
|
+
if explore:
|
|
168
|
+
score = self.ledger.selection_score(model, category, complexity, total)
|
|
169
|
+
else:
|
|
170
|
+
score = self.ledger.stat(model, category, complexity).quality_score()
|
|
171
|
+
return score / self._cost(model, category, complexity)
|
|
172
|
+
|
|
173
|
+
return max(models, key=value)
|
|
174
|
+
|
|
175
|
+
def _mature(self, category: str, complexity: str) -> bool:
|
|
176
|
+
"""True once a cell has enough data to stop exploring (auto mode)."""
|
|
177
|
+
return (
|
|
178
|
+
self.ledger.total_observations(category, complexity)
|
|
179
|
+
>= self.maturity_threshold
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _explore_on_first(self, category: str, complexity: str) -> bool:
|
|
183
|
+
if self.auto:
|
|
184
|
+
# Self-regulating: explore cheap/free models on easy tasks while the
|
|
185
|
+
# cell is immature, then settle into conservative cheap-first.
|
|
186
|
+
# Only trivial/small tasks are explored on the FIRST attempt: on a
|
|
187
|
+
# large repo, unproven free models returned no usable edits on medium
|
|
188
|
+
# refactor tasks (the emathy run burned first attempts on null
|
|
189
|
+
# responses). A cheap model can still earn medium first-attempt use
|
|
190
|
+
# once PROVEN (the conservative branch picks a proven cheap model).
|
|
191
|
+
if self._mature(category, complexity):
|
|
192
|
+
return False
|
|
193
|
+
return complexity in ("trivial", "small")
|
|
194
|
+
if self.posture == "aggressive":
|
|
195
|
+
return True
|
|
196
|
+
if self.posture == "balanced":
|
|
197
|
+
return complexity in ("trivial", "small", "medium")
|
|
198
|
+
return False # conservative
|
|
199
|
+
|
|
200
|
+
def select(
|
|
201
|
+
self, category: str, complexity: str, attempt: int, max_attempts: int
|
|
202
|
+
) -> Optional[str]:
|
|
203
|
+
"""Choose a model id for this attempt, or None to use the default.
|
|
204
|
+
|
|
205
|
+
Returns None when disabled or when no escalation ladder is configured,
|
|
206
|
+
leaving the executor's existing static routing in charge.
|
|
207
|
+
"""
|
|
208
|
+
if not self.enabled:
|
|
209
|
+
return None
|
|
210
|
+
rungs = self._rungs()
|
|
211
|
+
if not rungs:
|
|
212
|
+
return None
|
|
213
|
+
last = len(rungs) - 1
|
|
214
|
+
|
|
215
|
+
# The final attempt always uses the strongest tier: the quality floor's
|
|
216
|
+
# safety net, so every prior attempt can afford to try something cheaper.
|
|
217
|
+
# final=True lifts the latency guard here (a slow-but-capable last resort
|
|
218
|
+
# beats giving up), but incompetent models are still excluded.
|
|
219
|
+
if attempt >= max_attempts - 1:
|
|
220
|
+
return self._pick(
|
|
221
|
+
self._tier_models(rungs[last]), category, complexity, True, final=True
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if attempt == 0 and not self._explore_on_first(category, complexity):
|
|
225
|
+
# Conservative first attempt: cheapest rung with a proven model, else
|
|
226
|
+
# the strongest tier. Never gamble the first impression on an
|
|
227
|
+
# unproven cheap model.
|
|
228
|
+
for tier in rungs:
|
|
229
|
+
proven = [
|
|
230
|
+
m
|
|
231
|
+
for m in self._tier_models(tier)
|
|
232
|
+
if self._proven(m, category, complexity)
|
|
233
|
+
]
|
|
234
|
+
if proven:
|
|
235
|
+
chosen = self._pick(proven, category, complexity, False)
|
|
236
|
+
self._announce_graduation(category, complexity, chosen)
|
|
237
|
+
return chosen
|
|
238
|
+
return self._pick(
|
|
239
|
+
self._tier_models(rungs[last]), category, complexity, True
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Climbing path: one rung up per attempt, capped at the strongest tier.
|
|
243
|
+
# If the target rung is emptied by the free/incompetence/latency filters,
|
|
244
|
+
# climb to the next (stronger) rung rather than fall back to the client
|
|
245
|
+
# default — a filtered tier should escalate, not disable selection.
|
|
246
|
+
target = min(attempt, last)
|
|
247
|
+
return self._pick_climbing(rungs, target, category, complexity)
|
|
248
|
+
|
|
249
|
+
def _pick_climbing(
|
|
250
|
+
self, rungs: List[str], start: int, category: str, complexity: str
|
|
251
|
+
) -> Optional[str]:
|
|
252
|
+
"""Pick from rung ``start``, climbing to each stronger rung in turn until
|
|
253
|
+
one yields a model the filters didn't drop; None only when every rung
|
|
254
|
+
from ``start`` up is empty."""
|
|
255
|
+
for i in range(start, len(rungs)):
|
|
256
|
+
chosen = self._pick(self._tier_models(rungs[i]), category, complexity, True)
|
|
257
|
+
if chosen:
|
|
258
|
+
return chosen
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def _announce_graduation(self, category: str, complexity: str, model: str) -> None:
|
|
262
|
+
"""Log the first time a cell settles on a proven cheap model (auto)."""
|
|
263
|
+
if not self.auto:
|
|
264
|
+
return
|
|
265
|
+
cell = (category, complexity)
|
|
266
|
+
if cell not in self._graduated:
|
|
267
|
+
self._graduated.add(cell)
|
|
268
|
+
logger.info(
|
|
269
|
+
f"[auto] {category}/{complexity} now using proven cheap model "
|
|
270
|
+
f"{model!r} on first attempt"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def is_ready(self, category: str, complexity: str) -> bool:
|
|
274
|
+
"""Whether this cell would use a proven cheap model on a first attempt."""
|
|
275
|
+
if not self.enabled:
|
|
276
|
+
return False
|
|
277
|
+
return any(
|
|
278
|
+
self._proven(m, category, complexity)
|
|
279
|
+
for tier in self._rungs()[:-1]
|
|
280
|
+
for m in self._tier_models(tier)
|
|
281
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared bounded best-effort runner for the optional gates.
|
|
2
|
+
|
|
3
|
+
Several optional gates — goal check, adversarial critic, runtime/web/vision/
|
|
4
|
+
mutation smoke, LSP diagnostics, and the MCP substrate — must be strictly
|
|
5
|
+
non-blocking: a slow or hung model, server, or subprocess can NEVER stall a
|
|
6
|
+
build. They all shared one shape: run the work in a daemon thread, join with a
|
|
7
|
+
hard timeout, and on timeout abandon the thread (it dies with the process) and
|
|
8
|
+
return a SKIP sentinel. This centralizes that shape so the call sites don't each
|
|
9
|
+
re-implement it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Callable, TypeVar
|
|
14
|
+
|
|
15
|
+
from misterdev.logging_setup import setup_logger
|
|
16
|
+
|
|
17
|
+
logger = setup_logger(__name__)
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_bounded(
|
|
23
|
+
fn: Callable[[], T], timeout: float, default: T, what: str = "operation"
|
|
24
|
+
) -> T:
|
|
25
|
+
"""Run ``fn()`` in a daemon thread, returning its result or ``default``.
|
|
26
|
+
|
|
27
|
+
Returns ``default`` when ``fn`` does not finish within ``timeout`` seconds
|
|
28
|
+
(the thread is abandoned and dies with the process) or when it raises (logged
|
|
29
|
+
at debug — a backstop, since ``fn`` is expected to handle its own expected
|
|
30
|
+
failures and return an appropriate value itself). ``what`` names the
|
|
31
|
+
operation for log context. The caller is guaranteed control back within
|
|
32
|
+
``timeout`` seconds and is never handed an exception.
|
|
33
|
+
"""
|
|
34
|
+
box = {"result": default}
|
|
35
|
+
|
|
36
|
+
def _worker() -> None:
|
|
37
|
+
try:
|
|
38
|
+
box["result"] = fn()
|
|
39
|
+
except Exception as e: # backstop: fn should catch its own failures
|
|
40
|
+
logger.debug(f"{what} failed in bounded runner: {e}")
|
|
41
|
+
box["result"] = default
|
|
42
|
+
|
|
43
|
+
worker = threading.Thread(target=_worker, daemon=True)
|
|
44
|
+
worker.start()
|
|
45
|
+
worker.join(timeout)
|
|
46
|
+
if worker.is_alive():
|
|
47
|
+
logger.warning(f"{what} timed out after {timeout}s; skipping (never blocks).")
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
return box["result"]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Engine-agnostic OCI container execution substrate (opt-in).
|
|
2
|
+
|
|
3
|
+
Runs gate commands (build/lint/test/golden) inside a throwaway container so a
|
|
4
|
+
build is exercised against a pinned, reproducible toolchain instead of whatever
|
|
5
|
+
happens to be installed on the host. Strictly best-effort and opt-in: it is used
|
|
6
|
+
only when ``environment.type`` is ``docker``/``container`` AND a usable OCI
|
|
7
|
+
engine is actually present. With no engine reachable (no daemon, CLI missing)
|
|
8
|
+
the orchestrator falls back to executing commands locally exactly as before, so
|
|
9
|
+
the feature can never hard-fail a build or require a daemon to exist.
|
|
10
|
+
|
|
11
|
+
Design mirrors :mod:`misterdev.core.context.lsp`: detection probes are
|
|
12
|
+
bounded by a short timeout (a hung/absent engine can never block), every failure
|
|
13
|
+
is logged at debug and degrades to a safe no-op, and the runtime knob is off by
|
|
14
|
+
default.
|
|
15
|
+
|
|
16
|
+
Git operations (branch/commit/revert) always stay on the host; only the gate
|
|
17
|
+
commands are routed through the container, executed against the repository
|
|
18
|
+
bind-mounted at the same path so produced paths line up with the host tree.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shlex
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from misterdev.logging_setup import setup_logger
|
|
28
|
+
|
|
29
|
+
logger = setup_logger(__name__)
|
|
30
|
+
|
|
31
|
+
# Engines we know how to drive, in preference order: rootless/daemonless first
|
|
32
|
+
# (podman, nerdctl), then docker, then the colima-managed docker socket. All
|
|
33
|
+
# four speak the same ``<engine> run`` CLI surface we rely on.
|
|
34
|
+
_PREFERRED_ENGINES: Tuple[str, ...] = ("podman", "docker", "nerdctl", "colima")
|
|
35
|
+
|
|
36
|
+
# How long an engine availability probe may take before we treat the engine as
|
|
37
|
+
# unavailable. A short bound so a wedged daemon can never stall detection.
|
|
38
|
+
_PROBE_TIMEOUT = 8
|
|
39
|
+
|
|
40
|
+
# Default image per detected project language. Conservative, widely-mirrored
|
|
41
|
+
# tags; a project may override with ``environment.image``.
|
|
42
|
+
_LANGUAGE_IMAGES = {
|
|
43
|
+
"python": "python:3.12-slim",
|
|
44
|
+
"rust": "rust:slim",
|
|
45
|
+
"node": "node:20-slim",
|
|
46
|
+
"javascript": "node:20-slim",
|
|
47
|
+
"typescript": "node:20-slim",
|
|
48
|
+
"go": "golang:1.22",
|
|
49
|
+
"ruby": "ruby:3.3-slim",
|
|
50
|
+
"java": "eclipse-temurin:21",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_DEFAULT_IMAGE = "debian:stable-slim"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _probe_engine(engine: str) -> bool:
|
|
57
|
+
"""True if ``engine`` is installed and its backend responds within the probe
|
|
58
|
+
timeout. Never raises: any failure (missing binary, unreachable daemon,
|
|
59
|
+
timeout) is reported as unavailable."""
|
|
60
|
+
if engine == "colima":
|
|
61
|
+
# colima is a VM manager, not a runtime CLI; a running colima exposes a
|
|
62
|
+
# docker engine. Treat it as available only when its status is running.
|
|
63
|
+
cmd = ["colima", "status"]
|
|
64
|
+
else:
|
|
65
|
+
# `info` requires a working backend (daemon for docker; none for
|
|
66
|
+
# podman/nerdctl rootless), so it distinguishes "CLI present" from
|
|
67
|
+
# "engine actually usable".
|
|
68
|
+
cmd = [engine, "info"]
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.run(
|
|
71
|
+
cmd,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=_PROBE_TIMEOUT,
|
|
75
|
+
)
|
|
76
|
+
return proc.returncode == 0
|
|
77
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
78
|
+
logger.debug(f"Container engine probe failed for {engine}: {e}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_engine(preferred: Optional[str] = None) -> Optional[str]:
|
|
83
|
+
"""Return the name of the first usable OCI engine, or ``None`` if none.
|
|
84
|
+
|
|
85
|
+
``preferred`` (from ``environment.engine``) is tried first when given; the
|
|
86
|
+
standard rootless-first order follows. ``colima`` resolves to ``docker`` as
|
|
87
|
+
the run CLI since colima manages a docker-compatible daemon.
|
|
88
|
+
"""
|
|
89
|
+
order: List[str] = []
|
|
90
|
+
if preferred:
|
|
91
|
+
order.append(preferred)
|
|
92
|
+
order.extend(e for e in _PREFERRED_ENGINES if e not in order)
|
|
93
|
+
for engine in order:
|
|
94
|
+
if _probe_engine(engine):
|
|
95
|
+
# colima provides a docker daemon; the actual run CLI is docker.
|
|
96
|
+
return "docker" if engine == "colima" else engine
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def image_for_language(language: Optional[str]) -> str:
|
|
101
|
+
"""Pick a default base image for ``language``; falls back to a generic image
|
|
102
|
+
when the language is unknown so the container can still run shell gates."""
|
|
103
|
+
return _LANGUAGE_IMAGES.get((language or "").lower(), _DEFAULT_IMAGE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ContainerEngine:
|
|
107
|
+
"""Thin wrapper over a detected OCI engine for running gate commands.
|
|
108
|
+
|
|
109
|
+
One instance per build. Commands run in a fresh ``--rm`` container with the
|
|
110
|
+
repository bind-mounted at ``mount_path`` and the process mapped to the
|
|
111
|
+
host uid/gid so artifacts are not left root-owned. The container is not kept
|
|
112
|
+
running between commands (each gate is a self-contained ``run``), which
|
|
113
|
+
keeps lifecycle trivial and leak-free while still pinning the toolchain.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
engine: str,
|
|
119
|
+
image: str,
|
|
120
|
+
host_path: Path,
|
|
121
|
+
mount_path: str = "/workspace",
|
|
122
|
+
network: Optional[str] = None,
|
|
123
|
+
memory: Optional[str] = None,
|
|
124
|
+
cpus: Optional[str] = None,
|
|
125
|
+
pids_limit: Optional[int] = None,
|
|
126
|
+
cap_drop: Optional[List[str]] = None,
|
|
127
|
+
security_opt: Optional[List[str]] = None,
|
|
128
|
+
):
|
|
129
|
+
self.engine = engine
|
|
130
|
+
self.image = image
|
|
131
|
+
self.host_path = Path(host_path).resolve()
|
|
132
|
+
self.mount_path = mount_path
|
|
133
|
+
# Egress control: "none" runs the container with no network (governance.
|
|
134
|
+
# network); any other value (or None) leaves the engine default, so the
|
|
135
|
+
# off path is byte-identical to before. This constrains CONTAINERIZED
|
|
136
|
+
# execution only — host execution and git stay on the host network.
|
|
137
|
+
self.network = network
|
|
138
|
+
# Optional resource caps (environment.memory / .cpus / .pids_limit). Each
|
|
139
|
+
# is emitted only when set, so an unconfigured engine produces the exact
|
|
140
|
+
# same argv as before. They bound a runaway gate (fork bomb, memory hog)
|
|
141
|
+
# in the throwaway container without affecting host execution.
|
|
142
|
+
self.memory = memory
|
|
143
|
+
self.cpus = cpus
|
|
144
|
+
self.pids_limit = pids_limit
|
|
145
|
+
# Optional sandbox hardening for running model-generated code with less
|
|
146
|
+
# trust: cap_drop (e.g. ["ALL"]) drops Linux capabilities; security_opt
|
|
147
|
+
# (e.g. ["no-new-privileges", "seccomp=/path/profile.json"]) passes
|
|
148
|
+
# --security-opt. Both emitted only when set; a bare string is accepted
|
|
149
|
+
# and wrapped, so ``cap_drop: ALL`` in YAML works too.
|
|
150
|
+
self.cap_drop = [cap_drop] if isinstance(cap_drop, str) else (cap_drop or [])
|
|
151
|
+
self.security_opt = (
|
|
152
|
+
[security_opt] if isinstance(security_opt, str) else (security_opt or [])
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def is_available(self) -> bool:
|
|
156
|
+
return bool(self.engine)
|
|
157
|
+
|
|
158
|
+
def _user_args(self) -> List[str]:
|
|
159
|
+
"""``--user uid:gid`` so host-bind-mounted writes are owned by the
|
|
160
|
+
invoking user, not root. Skipped on platforms without uid/gid
|
|
161
|
+
(Windows), where the flag is meaningless."""
|
|
162
|
+
getuid = getattr(os, "getuid", None)
|
|
163
|
+
getgid = getattr(os, "getgid", None)
|
|
164
|
+
if getuid is None or getgid is None:
|
|
165
|
+
return []
|
|
166
|
+
return ["--user", f"{getuid()}:{getgid()}"]
|
|
167
|
+
|
|
168
|
+
def wrap_command(self, command: str, timeout: int) -> List[str]:
|
|
169
|
+
"""Build the argv that runs ``command`` inside a throwaway container.
|
|
170
|
+
|
|
171
|
+
The user command is passed verbatim to ``sh -c`` inside the container,
|
|
172
|
+
so shell features (``&&``, pipes, globs) behave as on the host. The repo
|
|
173
|
+
is the working directory via the bind mount.
|
|
174
|
+
"""
|
|
175
|
+
argv = [
|
|
176
|
+
self.engine,
|
|
177
|
+
"run",
|
|
178
|
+
"--rm",
|
|
179
|
+
"-v",
|
|
180
|
+
f"{self.host_path}:{self.mount_path}",
|
|
181
|
+
"-w",
|
|
182
|
+
self.mount_path,
|
|
183
|
+
]
|
|
184
|
+
if self.network == "none":
|
|
185
|
+
argv.extend(["--network", "none"])
|
|
186
|
+
if self.memory:
|
|
187
|
+
argv.extend(["--memory", str(self.memory)])
|
|
188
|
+
if self.cpus:
|
|
189
|
+
argv.extend(["--cpus", str(self.cpus)])
|
|
190
|
+
if self.pids_limit:
|
|
191
|
+
argv.extend(["--pids-limit", str(self.pids_limit)])
|
|
192
|
+
for cap in self.cap_drop:
|
|
193
|
+
argv.extend(["--cap-drop", str(cap)])
|
|
194
|
+
for opt in self.security_opt:
|
|
195
|
+
argv.extend(["--security-opt", str(opt)])
|
|
196
|
+
argv.extend(self._user_args())
|
|
197
|
+
argv.extend([self.image, "sh", "-c", command])
|
|
198
|
+
return argv
|
|
199
|
+
|
|
200
|
+
def run(self, command: str, timeout: int = 180) -> Tuple[bool, str]:
|
|
201
|
+
"""Execute ``command`` inside the container. Returns ``(ok, output)``
|
|
202
|
+
with the same contract as the local runner, so callers are agnostic to
|
|
203
|
+
where the command ran. Never raises: engine/timeout failures return
|
|
204
|
+
``(False, message)``."""
|
|
205
|
+
argv = self.wrap_command(command, timeout)
|
|
206
|
+
logger.debug(f"Container exec ({self.engine}): {shlex.join(argv)}")
|
|
207
|
+
try:
|
|
208
|
+
proc = subprocess.run(
|
|
209
|
+
argv,
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
timeout=timeout,
|
|
213
|
+
)
|
|
214
|
+
output = proc.stdout
|
|
215
|
+
if proc.stderr:
|
|
216
|
+
output += "\n" + proc.stderr
|
|
217
|
+
return proc.returncode == 0, output
|
|
218
|
+
except subprocess.TimeoutExpired:
|
|
219
|
+
return False, f"Container command timed out after {timeout}s: {command}"
|
|
220
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
221
|
+
return False, f"Container command failed: {e}"
|