continuous-refactoring 0.1.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.
- continuous_refactoring/__init__.py +74 -0
- continuous_refactoring/__main__.py +8 -0
- continuous_refactoring/agent.py +733 -0
- continuous_refactoring/artifacts.py +431 -0
- continuous_refactoring/cli.py +687 -0
- continuous_refactoring/commit_messages.py +68 -0
- continuous_refactoring/config.py +377 -0
- continuous_refactoring/decisions.py +197 -0
- continuous_refactoring/effort.py +159 -0
- continuous_refactoring/failure_report.py +329 -0
- continuous_refactoring/git.py +134 -0
- continuous_refactoring/loop.py +1137 -0
- continuous_refactoring/migration_manifest_codec.py +190 -0
- continuous_refactoring/migration_tick.py +468 -0
- continuous_refactoring/migrations.py +251 -0
- continuous_refactoring/phases.py +690 -0
- continuous_refactoring/planning.py +588 -0
- continuous_refactoring/prompts.py +900 -0
- continuous_refactoring/refactor_attempts.py +424 -0
- continuous_refactoring/review_cli.py +136 -0
- continuous_refactoring/routing.py +133 -0
- continuous_refactoring/routing_pipeline.py +313 -0
- continuous_refactoring/scope_candidates.py +421 -0
- continuous_refactoring/scope_expansion.py +219 -0
- continuous_refactoring/targeting.py +274 -0
- continuous_refactoring-0.1.0.dist-info/METADATA +272 -0
- continuous_refactoring-0.1.0.dist-info/RECORD +30 -0
- continuous_refactoring-0.1.0.dist-info/WHEEL +4 -0
- continuous_refactoring-0.1.0.dist-info/entry_points.txt +2 -0
- continuous_refactoring-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
from continuous_refactoring.artifacts import RunArtifacts
|
|
15
|
+
from continuous_refactoring.migrations import MigrationManifest
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"run_baseline_checks",
|
|
19
|
+
"run_loop",
|
|
20
|
+
"run_migrations_focused_loop",
|
|
21
|
+
"run_once",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
from continuous_refactoring.artifacts import (
|
|
25
|
+
ContinuousRefactorError,
|
|
26
|
+
_effort_log_suffix,
|
|
27
|
+
create_run_artifacts,
|
|
28
|
+
)
|
|
29
|
+
from continuous_refactoring.agent import (
|
|
30
|
+
maybe_run_agent,
|
|
31
|
+
run_tests,
|
|
32
|
+
summarize_output,
|
|
33
|
+
)
|
|
34
|
+
from continuous_refactoring.config import (
|
|
35
|
+
default_taste_text,
|
|
36
|
+
load_taste,
|
|
37
|
+
resolve_live_migrations_dir,
|
|
38
|
+
resolve_project,
|
|
39
|
+
)
|
|
40
|
+
from continuous_refactoring.commit_messages import (
|
|
41
|
+
build_commit_message,
|
|
42
|
+
commit_rationale,
|
|
43
|
+
)
|
|
44
|
+
from continuous_refactoring.decisions import (
|
|
45
|
+
DecisionRecord,
|
|
46
|
+
read_status,
|
|
47
|
+
sanitize_text,
|
|
48
|
+
)
|
|
49
|
+
from continuous_refactoring.effort import (
|
|
50
|
+
EffortBudget,
|
|
51
|
+
EffortResolution,
|
|
52
|
+
resolve_effort_budget,
|
|
53
|
+
resolve_requested_effort,
|
|
54
|
+
)
|
|
55
|
+
from continuous_refactoring.failure_report import effective_record, persist_decision
|
|
56
|
+
from continuous_refactoring.git import (
|
|
57
|
+
discard_workspace_changes,
|
|
58
|
+
get_head_sha,
|
|
59
|
+
require_clean_worktree,
|
|
60
|
+
revert_to,
|
|
61
|
+
run_command,
|
|
62
|
+
)
|
|
63
|
+
from continuous_refactoring.migrations import (
|
|
64
|
+
phase_file_reference,
|
|
65
|
+
resolve_current_phase,
|
|
66
|
+
)
|
|
67
|
+
from continuous_refactoring.prompts import (
|
|
68
|
+
DEFAULT_FIX_AMENDMENT,
|
|
69
|
+
DEFAULT_REFACTORING_PROMPT,
|
|
70
|
+
compose_full_prompt,
|
|
71
|
+
prompt_file_text,
|
|
72
|
+
)
|
|
73
|
+
from continuous_refactoring.refactor_attempts import (
|
|
74
|
+
_finalize_commit,
|
|
75
|
+
_preserve_workspace_tree,
|
|
76
|
+
_retry_context,
|
|
77
|
+
_run_refactor_attempt,
|
|
78
|
+
)
|
|
79
|
+
import continuous_refactoring.migration_tick as migration_tick
|
|
80
|
+
import continuous_refactoring.refactor_attempts as refactor_attempts_module
|
|
81
|
+
import continuous_refactoring.routing_pipeline as routing_pipeline
|
|
82
|
+
from continuous_refactoring.targeting import Target, parse_paths_arg, resolve_targets
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_baseline_checks(
|
|
86
|
+
test_command: str,
|
|
87
|
+
repo_root: Path,
|
|
88
|
+
*,
|
|
89
|
+
stdout_path: Path,
|
|
90
|
+
stderr_path: Path,
|
|
91
|
+
) -> tuple[bool, str]:
|
|
92
|
+
result = run_tests(
|
|
93
|
+
test_command,
|
|
94
|
+
repo_root,
|
|
95
|
+
stdout_path=stdout_path,
|
|
96
|
+
stderr_path=stderr_path,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode == 0:
|
|
99
|
+
return True, ""
|
|
100
|
+
return False, summarize_output(result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_taste_safe(repo_root: Path) -> str:
|
|
104
|
+
try:
|
|
105
|
+
project = resolve_project(repo_root)
|
|
106
|
+
return load_taste(project)
|
|
107
|
+
except ContinuousRefactorError:
|
|
108
|
+
try:
|
|
109
|
+
return load_taste(None)
|
|
110
|
+
except ContinuousRefactorError:
|
|
111
|
+
return default_taste_text()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_live_migrations_dir(repo_root: Path) -> Path | None:
|
|
115
|
+
try:
|
|
116
|
+
project = resolve_project(repo_root)
|
|
117
|
+
except ContinuousRefactorError:
|
|
118
|
+
return None
|
|
119
|
+
return resolve_live_migrations_dir(project)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _effort_budget_from_args(args: argparse.Namespace) -> EffortBudget:
|
|
123
|
+
default_effort = getattr(args, "default_effort", getattr(args, "effort", None))
|
|
124
|
+
max_allowed_effort = getattr(args, "max_allowed_effort", None)
|
|
125
|
+
return resolve_effort_budget(default_effort, max_allowed_effort)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _target_effort_budget(
|
|
129
|
+
budget: EffortBudget,
|
|
130
|
+
target: Target,
|
|
131
|
+
) -> tuple[EffortBudget, EffortResolution]:
|
|
132
|
+
has_override = target.effort_override is not None
|
|
133
|
+
resolution = resolve_requested_effort(
|
|
134
|
+
budget,
|
|
135
|
+
target.effort_override,
|
|
136
|
+
source="target-override" if has_override else "default",
|
|
137
|
+
reason=(
|
|
138
|
+
"target effort override capped by run budget"
|
|
139
|
+
if has_override
|
|
140
|
+
else "run default effort"
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
return (
|
|
144
|
+
EffortBudget(
|
|
145
|
+
default_effort=resolution.effective_effort,
|
|
146
|
+
max_allowed_effort=budget.max_allowed_effort,
|
|
147
|
+
),
|
|
148
|
+
resolution,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _log_effort_budget(artifacts: RunArtifacts, budget: EffortBudget) -> None:
|
|
153
|
+
artifacts.log(
|
|
154
|
+
"INFO",
|
|
155
|
+
(
|
|
156
|
+
"effort budget: "
|
|
157
|
+
f"default={budget.default_effort}, max={budget.max_allowed_effort}"
|
|
158
|
+
),
|
|
159
|
+
event="effort_budget_configured",
|
|
160
|
+
default_effort=budget.default_effort,
|
|
161
|
+
max_allowed_effort=budget.max_allowed_effort,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _log_effort_resolution(
|
|
166
|
+
artifacts: RunArtifacts,
|
|
167
|
+
resolution: EffortResolution,
|
|
168
|
+
*,
|
|
169
|
+
attempt: int,
|
|
170
|
+
target: str,
|
|
171
|
+
) -> None:
|
|
172
|
+
artifacts.log(
|
|
173
|
+
"INFO",
|
|
174
|
+
(
|
|
175
|
+
"effort resolved: "
|
|
176
|
+
f"{resolution.requested_effort} -> {resolution.effective_effort}"
|
|
177
|
+
),
|
|
178
|
+
event="effort_resolved",
|
|
179
|
+
attempt=attempt,
|
|
180
|
+
target=target,
|
|
181
|
+
**resolution.event_fields(),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _resolve_base_prompt(args: argparse.Namespace) -> str:
|
|
185
|
+
if args.refactoring_prompt:
|
|
186
|
+
return prompt_file_text(args.refactoring_prompt)
|
|
187
|
+
return DEFAULT_REFACTORING_PROMPT
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_target_fallback(scope_instruction: str | None) -> Target:
|
|
191
|
+
return Target(
|
|
192
|
+
description="general refactoring",
|
|
193
|
+
files=(),
|
|
194
|
+
scoping=scope_instruction,
|
|
195
|
+
model_override=None,
|
|
196
|
+
effort_override=None,
|
|
197
|
+
provenance="fallback",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _effective_max_attempts(raw: int | None) -> int | None:
|
|
202
|
+
"""Normalize --max-attempts: None -> 1, 0 -> None (unlimited), N -> N."""
|
|
203
|
+
if raw is None:
|
|
204
|
+
return 1
|
|
205
|
+
if raw == 0:
|
|
206
|
+
return None
|
|
207
|
+
return raw
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_fix_amendment_text(args: argparse.Namespace) -> str:
|
|
211
|
+
if args.fix_prompt:
|
|
212
|
+
return prompt_file_text(args.fix_prompt)
|
|
213
|
+
return DEFAULT_FIX_AMENDMENT
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _resolve_targets_from_args(
|
|
217
|
+
args: argparse.Namespace,
|
|
218
|
+
repo_root: Path,
|
|
219
|
+
) -> list[Target]:
|
|
220
|
+
return resolve_targets(
|
|
221
|
+
extensions=args.extensions,
|
|
222
|
+
globs=args.globs,
|
|
223
|
+
targets_path=args.targets,
|
|
224
|
+
paths=parse_paths_arg(args.paths),
|
|
225
|
+
repo_root=repo_root,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _action_limit(
|
|
230
|
+
args: argparse.Namespace,
|
|
231
|
+
targets: list[Target],
|
|
232
|
+
) -> int | None:
|
|
233
|
+
if args.max_refactors is None and args.targets:
|
|
234
|
+
return len(targets)
|
|
235
|
+
if args.max_refactors == 0:
|
|
236
|
+
return None
|
|
237
|
+
return args.max_refactors
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _has_action_budget(actions_completed: int, action_limit: int | None) -> bool:
|
|
241
|
+
return action_limit is None or actions_completed < action_limit
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _action_banner(action_index: int, action_limit: int | None) -> str:
|
|
245
|
+
if action_limit is None:
|
|
246
|
+
return f"\n── Action {action_index} ──"
|
|
247
|
+
return f"\n── Action {action_index}/{action_limit} ──"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _print_migration_probe(live_dir: Path, effort_budget: EffortBudget) -> None:
|
|
251
|
+
candidates = migration_tick.enumerate_eligible_manifests(
|
|
252
|
+
live_dir,
|
|
253
|
+
datetime.now(timezone.utc),
|
|
254
|
+
effort_budget,
|
|
255
|
+
)
|
|
256
|
+
if not candidates:
|
|
257
|
+
print("No runnable migrations; selecting a target")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if len(candidates) > 1:
|
|
261
|
+
print(f"Examining live migrations: {len(candidates)} eligible")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
manifest, _manifest_path = candidates[0]
|
|
265
|
+
print(f"Examining migration: migration/{manifest.name}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _MigrationProbeArtifacts:
|
|
269
|
+
def __init__(self, artifacts: RunArtifacts, action_index: int) -> None:
|
|
270
|
+
self._artifacts = artifacts
|
|
271
|
+
self.root = artifacts.root / "migration-probes" / f"action-{action_index:03d}"
|
|
272
|
+
|
|
273
|
+
def attempt_dir(self, attempt: int, retry: int = 1) -> Path:
|
|
274
|
+
if attempt < 1:
|
|
275
|
+
raise ValueError(f"attempt must be >= 1, got {attempt}")
|
|
276
|
+
if retry < 1:
|
|
277
|
+
raise ValueError(f"retry must be >= 1, got {retry}")
|
|
278
|
+
base = self.root / f"attempt-{attempt:03d}"
|
|
279
|
+
path = base if retry == 1 else base / f"retry-{retry:02d}"
|
|
280
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
return path
|
|
282
|
+
|
|
283
|
+
def log_call_started(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
attempt: int,
|
|
287
|
+
retry: int,
|
|
288
|
+
target: str,
|
|
289
|
+
display_target: str | None = None,
|
|
290
|
+
call_role: str,
|
|
291
|
+
phase_reached: str | None = None,
|
|
292
|
+
effort: dict[str, object] | None = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
effort_fields = dict(effort or {})
|
|
295
|
+
human_target = display_target or target
|
|
296
|
+
self._artifacts.log(
|
|
297
|
+
"INFO",
|
|
298
|
+
f"migration call start: {call_role} — {human_target}"
|
|
299
|
+
f"{_effort_log_suffix(effort)}",
|
|
300
|
+
event="migration_call_started",
|
|
301
|
+
migration_attempt=attempt,
|
|
302
|
+
retry=retry,
|
|
303
|
+
target=target,
|
|
304
|
+
call_role=call_role,
|
|
305
|
+
phase_reached=phase_reached or call_role,
|
|
306
|
+
**effort_fields,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def log_call_finished(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
attempt: int,
|
|
313
|
+
retry: int,
|
|
314
|
+
target: str,
|
|
315
|
+
call_role: str,
|
|
316
|
+
display_target: str | None = None,
|
|
317
|
+
phase_reached: str | None = None,
|
|
318
|
+
status: str,
|
|
319
|
+
level: str = "INFO",
|
|
320
|
+
returncode: int | None = None,
|
|
321
|
+
summary: str | None = None,
|
|
322
|
+
effort: dict[str, object] | None = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
effort_fields = dict(effort or {})
|
|
325
|
+
human_target = display_target or target
|
|
326
|
+
self._artifacts.log(
|
|
327
|
+
level,
|
|
328
|
+
f"migration call {status}: {call_role} — {human_target}"
|
|
329
|
+
f"{_effort_log_suffix(effort)}",
|
|
330
|
+
event="migration_call_finished",
|
|
331
|
+
migration_attempt=attempt,
|
|
332
|
+
retry=retry,
|
|
333
|
+
target=target,
|
|
334
|
+
call_role=call_role,
|
|
335
|
+
phase_reached=phase_reached or call_role,
|
|
336
|
+
call_status=status,
|
|
337
|
+
returncode=returncode,
|
|
338
|
+
summary=summary,
|
|
339
|
+
**effort_fields,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def record_commit(self, attempt: int, phase: str, commit_sha: str) -> None:
|
|
343
|
+
self._artifacts.record_commit(attempt, phase, commit_sha)
|
|
344
|
+
|
|
345
|
+
def log(self, level: str, message: str, **fields: object) -> None:
|
|
346
|
+
self._artifacts.log(level, message, **fields)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _sleep_between_actions(
|
|
350
|
+
sleep_seconds: float,
|
|
351
|
+
*,
|
|
352
|
+
artifacts: RunArtifacts,
|
|
353
|
+
action_index: int,
|
|
354
|
+
has_more_actions: bool,
|
|
355
|
+
) -> None:
|
|
356
|
+
if sleep_seconds <= 0 or not has_more_actions:
|
|
357
|
+
return
|
|
358
|
+
artifacts.log(
|
|
359
|
+
"INFO",
|
|
360
|
+
f"Sleeping {sleep_seconds:g}s before next action",
|
|
361
|
+
event="sleep_between_actions",
|
|
362
|
+
attempt=action_index,
|
|
363
|
+
sleep_seconds=sleep_seconds,
|
|
364
|
+
)
|
|
365
|
+
print(f"Sleeping {sleep_seconds:g}s before next action")
|
|
366
|
+
time.sleep(sleep_seconds)
|
|
367
|
+
def run_once(args: argparse.Namespace) -> int:
|
|
368
|
+
repo_root = args.repo_root.resolve()
|
|
369
|
+
timeout = args.timeout or 900
|
|
370
|
+
base_effort_budget = _effort_budget_from_args(args)
|
|
371
|
+
max_attempts_effective = _effective_max_attempts(
|
|
372
|
+
getattr(args, "max_attempts", None)
|
|
373
|
+
)
|
|
374
|
+
taste = _load_taste_safe(repo_root)
|
|
375
|
+
|
|
376
|
+
targets = _resolve_targets_from_args(args, repo_root)
|
|
377
|
+
target = (
|
|
378
|
+
random.choice(targets)
|
|
379
|
+
if targets
|
|
380
|
+
else _build_target_fallback(args.scope_instruction)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
base_prompt = _resolve_base_prompt(args)
|
|
384
|
+
model = target.model_override or args.model
|
|
385
|
+
target_effort_budget, effort_resolution = _target_effort_budget(
|
|
386
|
+
base_effort_budget,
|
|
387
|
+
target,
|
|
388
|
+
)
|
|
389
|
+
effort = target_effort_budget.default_effort
|
|
390
|
+
|
|
391
|
+
artifacts = create_run_artifacts(
|
|
392
|
+
repo_root,
|
|
393
|
+
agent=args.agent,
|
|
394
|
+
model=model,
|
|
395
|
+
effort=effort,
|
|
396
|
+
default_effort=base_effort_budget.default_effort,
|
|
397
|
+
max_allowed_effort=base_effort_budget.max_allowed_effort,
|
|
398
|
+
test_command=args.validation_command,
|
|
399
|
+
)
|
|
400
|
+
artifacts.log("INFO", f"run artifacts: {artifacts.root}", event="artifacts_ready")
|
|
401
|
+
_log_effort_budget(artifacts, base_effort_budget)
|
|
402
|
+
_log_effort_resolution(
|
|
403
|
+
artifacts,
|
|
404
|
+
effort_resolution,
|
|
405
|
+
attempt=1,
|
|
406
|
+
target=target.description,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
final_status = "running"
|
|
410
|
+
error_message: str | None = None
|
|
411
|
+
head_before: str | None = None
|
|
412
|
+
try:
|
|
413
|
+
require_clean_worktree(repo_root)
|
|
414
|
+
|
|
415
|
+
baseline_ok, baseline_context = run_baseline_checks(
|
|
416
|
+
args.validation_command,
|
|
417
|
+
repo_root,
|
|
418
|
+
stdout_path=artifacts.baseline_dir("initial") / "tests.stdout.log",
|
|
419
|
+
stderr_path=artifacts.baseline_dir("initial") / "tests.stderr.log",
|
|
420
|
+
)
|
|
421
|
+
if not baseline_ok:
|
|
422
|
+
final_status = "baseline_failed"
|
|
423
|
+
raise ContinuousRefactorError(
|
|
424
|
+
f"Baseline validation failed\n{baseline_context}"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
artifacts.mark_attempt_started(1)
|
|
428
|
+
|
|
429
|
+
print(f"\n── Target: {target.description} ──")
|
|
430
|
+
route_result = routing_pipeline.route_and_run(
|
|
431
|
+
target,
|
|
432
|
+
taste,
|
|
433
|
+
repo_root,
|
|
434
|
+
artifacts,
|
|
435
|
+
live_dir=_resolve_live_migrations_dir(repo_root),
|
|
436
|
+
agent=args.agent,
|
|
437
|
+
model=model,
|
|
438
|
+
effort=effort,
|
|
439
|
+
effort_budget=target_effort_budget,
|
|
440
|
+
effort_metadata=effort_resolution.event_fields(),
|
|
441
|
+
timeout=timeout,
|
|
442
|
+
commit_message_prefix="continuous refactor",
|
|
443
|
+
validation_command=args.validation_command,
|
|
444
|
+
max_attempts=max_attempts_effective,
|
|
445
|
+
attempt=1,
|
|
446
|
+
finalize_commit=_finalize_commit,
|
|
447
|
+
)
|
|
448
|
+
target = route_result.target
|
|
449
|
+
if route_result.outcome == "commit":
|
|
450
|
+
final_status = "completed"
|
|
451
|
+
return 0
|
|
452
|
+
if route_result.outcome in {"abandon", "blocked"}:
|
|
453
|
+
final_status = "migration_failed"
|
|
454
|
+
raise ContinuousRefactorError(
|
|
455
|
+
route_result.decision_record.summary
|
|
456
|
+
if route_result.decision_record is not None
|
|
457
|
+
else "Migration phase execution failed"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
prompt = compose_full_prompt(
|
|
461
|
+
base_prompt=base_prompt,
|
|
462
|
+
taste=taste,
|
|
463
|
+
target=target,
|
|
464
|
+
scope_instruction=args.scope_instruction,
|
|
465
|
+
validation_command=args.validation_command,
|
|
466
|
+
attempt=1,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
head_before = get_head_sha(repo_root)
|
|
470
|
+
|
|
471
|
+
attempt_dir = artifacts.attempt_dir(1) / "refactor"
|
|
472
|
+
last_message_path = (
|
|
473
|
+
attempt_dir / "agent-last-message.md" if args.agent == "codex" else None
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
artifacts.log_call_started(
|
|
477
|
+
attempt=1,
|
|
478
|
+
retry=1,
|
|
479
|
+
target=target.description,
|
|
480
|
+
call_role="refactor",
|
|
481
|
+
effort=effort_resolution.event_fields(),
|
|
482
|
+
)
|
|
483
|
+
try:
|
|
484
|
+
agent_result = maybe_run_agent(
|
|
485
|
+
agent=args.agent,
|
|
486
|
+
model=model,
|
|
487
|
+
effort=effort,
|
|
488
|
+
prompt=prompt,
|
|
489
|
+
repo_root=repo_root,
|
|
490
|
+
stdout_path=attempt_dir / "agent.stdout.log",
|
|
491
|
+
stderr_path=attempt_dir / "agent.stderr.log",
|
|
492
|
+
last_message_path=last_message_path,
|
|
493
|
+
mirror_to_terminal=args.show_agent_logs,
|
|
494
|
+
timeout=timeout,
|
|
495
|
+
)
|
|
496
|
+
except ContinuousRefactorError as error:
|
|
497
|
+
artifacts.log_call_finished(
|
|
498
|
+
attempt=1,
|
|
499
|
+
retry=1,
|
|
500
|
+
target=target.description,
|
|
501
|
+
call_role="refactor",
|
|
502
|
+
status="failed",
|
|
503
|
+
level="WARN",
|
|
504
|
+
summary=str(error),
|
|
505
|
+
effort=effort_resolution.event_fields(),
|
|
506
|
+
)
|
|
507
|
+
raise
|
|
508
|
+
|
|
509
|
+
if agent_result.returncode != 0:
|
|
510
|
+
artifacts.log_call_finished(
|
|
511
|
+
attempt=1,
|
|
512
|
+
retry=1,
|
|
513
|
+
target=target.description,
|
|
514
|
+
call_role="refactor",
|
|
515
|
+
status="failed",
|
|
516
|
+
level="WARN",
|
|
517
|
+
returncode=agent_result.returncode,
|
|
518
|
+
summary=f"Agent failed with exit code {agent_result.returncode}",
|
|
519
|
+
effort=effort_resolution.event_fields(),
|
|
520
|
+
)
|
|
521
|
+
final_status = "agent_failed"
|
|
522
|
+
raise ContinuousRefactorError(
|
|
523
|
+
f"Agent failed with exit code {agent_result.returncode}"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
artifacts.log_call_finished(
|
|
527
|
+
attempt=1,
|
|
528
|
+
retry=1,
|
|
529
|
+
target=target.description,
|
|
530
|
+
call_role="refactor",
|
|
531
|
+
status="finished",
|
|
532
|
+
returncode=agent_result.returncode,
|
|
533
|
+
effort=effort_resolution.event_fields(),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
validation_result = run_tests(
|
|
537
|
+
args.validation_command,
|
|
538
|
+
repo_root,
|
|
539
|
+
stdout_path=attempt_dir / "tests.stdout.log",
|
|
540
|
+
stderr_path=attempt_dir / "tests.stderr.log",
|
|
541
|
+
mirror_to_terminal=args.show_command_logs,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if validation_result.returncode != 0:
|
|
545
|
+
final_status = "validation_failed"
|
|
546
|
+
raise ContinuousRefactorError("Validation failed after agent run")
|
|
547
|
+
|
|
548
|
+
agent_status = read_status(
|
|
549
|
+
args.agent,
|
|
550
|
+
last_message_path=last_message_path,
|
|
551
|
+
fallback_text=agent_result.stdout,
|
|
552
|
+
)
|
|
553
|
+
_finalize_commit(
|
|
554
|
+
repo_root,
|
|
555
|
+
head_before,
|
|
556
|
+
build_commit_message(
|
|
557
|
+
"continuous refactor: run-once",
|
|
558
|
+
why=commit_rationale(
|
|
559
|
+
agent_status,
|
|
560
|
+
fallback=(
|
|
561
|
+
sanitize_text(agent_result.stdout, repo_root)
|
|
562
|
+
or "Validated run-once cleanup."
|
|
563
|
+
),
|
|
564
|
+
repo_root=repo_root,
|
|
565
|
+
),
|
|
566
|
+
validation=args.validation_command,
|
|
567
|
+
),
|
|
568
|
+
artifacts=artifacts,
|
|
569
|
+
attempt=1,
|
|
570
|
+
phase="run_once",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
diff_stat = run_command(
|
|
574
|
+
["git", "show", "--stat", "HEAD"],
|
|
575
|
+
cwd=repo_root,
|
|
576
|
+
check=False,
|
|
577
|
+
)
|
|
578
|
+
print(diff_stat.stdout)
|
|
579
|
+
final_status = "completed"
|
|
580
|
+
return 0
|
|
581
|
+
|
|
582
|
+
except ContinuousRefactorError as error:
|
|
583
|
+
if head_before is not None:
|
|
584
|
+
revert_to(repo_root, head_before)
|
|
585
|
+
if final_status == "running":
|
|
586
|
+
final_status = "failed"
|
|
587
|
+
error_message = str(error)
|
|
588
|
+
raise
|
|
589
|
+
except KeyboardInterrupt:
|
|
590
|
+
final_status = "interrupted"
|
|
591
|
+
artifacts.log("WARN", "Interrupted", event="interrupted")
|
|
592
|
+
print(f"\nArtifact logs: {artifacts.root}", file=sys.stderr)
|
|
593
|
+
return 130
|
|
594
|
+
finally:
|
|
595
|
+
artifacts.finish(final_status, error_message=error_message)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def run_loop(args: argparse.Namespace) -> int:
|
|
599
|
+
repo_root = args.repo_root.resolve()
|
|
600
|
+
timeout = args.timeout or 1800
|
|
601
|
+
sleep_seconds = getattr(args, "sleep", 0.0)
|
|
602
|
+
max_consecutive = args.max_consecutive_failures
|
|
603
|
+
base_effort_budget = _effort_budget_from_args(args)
|
|
604
|
+
max_attempts_effective = _effective_max_attempts(
|
|
605
|
+
getattr(args, "max_attempts", None)
|
|
606
|
+
)
|
|
607
|
+
taste = _load_taste_safe(repo_root)
|
|
608
|
+
|
|
609
|
+
targets = _resolve_targets_from_args(args, repo_root)
|
|
610
|
+
random.shuffle(targets)
|
|
611
|
+
|
|
612
|
+
fell_back_to_scope = False
|
|
613
|
+
if not targets:
|
|
614
|
+
targets = [_build_target_fallback(args.scope_instruction)]
|
|
615
|
+
fell_back_to_scope = bool(args.extensions or args.globs or args.paths)
|
|
616
|
+
action_limit = _action_limit(args, targets)
|
|
617
|
+
live_dir = _resolve_live_migrations_dir(repo_root)
|
|
618
|
+
|
|
619
|
+
base_prompt = _resolve_base_prompt(args)
|
|
620
|
+
fix_amendment_text = _resolve_fix_amendment_text(args)
|
|
621
|
+
|
|
622
|
+
artifacts = create_run_artifacts(
|
|
623
|
+
repo_root,
|
|
624
|
+
agent=args.agent,
|
|
625
|
+
model=args.model,
|
|
626
|
+
effort=base_effort_budget.default_effort,
|
|
627
|
+
default_effort=base_effort_budget.default_effort,
|
|
628
|
+
max_allowed_effort=base_effort_budget.max_allowed_effort,
|
|
629
|
+
test_command=args.validation_command,
|
|
630
|
+
)
|
|
631
|
+
artifacts.log("INFO", f"run artifacts: {artifacts.root}", event="artifacts_ready")
|
|
632
|
+
_log_effort_budget(artifacts, base_effort_budget)
|
|
633
|
+
if max_attempts_effective is None:
|
|
634
|
+
artifacts.log(
|
|
635
|
+
"WARN",
|
|
636
|
+
"max_attempts=0: unlimited retries; permanently-broken targets will not exit",
|
|
637
|
+
event="max_attempts_unlimited",
|
|
638
|
+
)
|
|
639
|
+
if fell_back_to_scope:
|
|
640
|
+
artifacts.log(
|
|
641
|
+
"INFO",
|
|
642
|
+
"Targeting patterns matched no tracked files; falling back to scope-instruction.",
|
|
643
|
+
event="targeting_fallback",
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
final_status = "running"
|
|
647
|
+
error_message: str | None = None
|
|
648
|
+
consecutive_failures = 0
|
|
649
|
+
source_index = 0
|
|
650
|
+
actions_completed = 0
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
require_clean_worktree(repo_root)
|
|
654
|
+
|
|
655
|
+
baseline_ok, baseline_context = run_baseline_checks(
|
|
656
|
+
args.validation_command,
|
|
657
|
+
repo_root,
|
|
658
|
+
stdout_path=artifacts.baseline_dir("initial") / "tests.stdout.log",
|
|
659
|
+
stderr_path=artifacts.baseline_dir("initial") / "tests.stderr.log",
|
|
660
|
+
)
|
|
661
|
+
if not baseline_ok:
|
|
662
|
+
final_status = "baseline_failed"
|
|
663
|
+
raise ContinuousRefactorError(
|
|
664
|
+
f"Baseline validation failed\n{baseline_context}"
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
while (
|
|
668
|
+
source_index < len(targets)
|
|
669
|
+
and _has_action_budget(actions_completed, action_limit)
|
|
670
|
+
):
|
|
671
|
+
action_index = actions_completed + 1
|
|
672
|
+
print(_action_banner(action_index, action_limit))
|
|
673
|
+
|
|
674
|
+
if live_dir is not None:
|
|
675
|
+
_print_migration_probe(live_dir, base_effort_budget)
|
|
676
|
+
migration_artifacts = _MigrationProbeArtifacts(artifacts, action_index)
|
|
677
|
+
migration_outcome, migration_record = migration_tick.try_migration_tick(
|
|
678
|
+
live_dir,
|
|
679
|
+
taste,
|
|
680
|
+
repo_root,
|
|
681
|
+
migration_artifacts,
|
|
682
|
+
agent=args.agent,
|
|
683
|
+
model=args.model,
|
|
684
|
+
effort=base_effort_budget.default_effort,
|
|
685
|
+
effort_budget=base_effort_budget,
|
|
686
|
+
timeout=timeout,
|
|
687
|
+
commit_message_prefix=args.commit_message_prefix,
|
|
688
|
+
validation_command=args.validation_command,
|
|
689
|
+
max_attempts=max_attempts_effective,
|
|
690
|
+
attempt=action_index,
|
|
691
|
+
finalize_commit=_finalize_commit,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if migration_outcome in {"commit", "abandon"}:
|
|
695
|
+
artifacts.mark_attempt_started(action_index)
|
|
696
|
+
if migration_record is not None:
|
|
697
|
+
persist_decision(
|
|
698
|
+
repo_root,
|
|
699
|
+
artifacts,
|
|
700
|
+
attempt=action_index,
|
|
701
|
+
retry=migration_record.retry_used,
|
|
702
|
+
validation_command=args.validation_command,
|
|
703
|
+
record=migration_record,
|
|
704
|
+
)
|
|
705
|
+
actions_completed += 1
|
|
706
|
+
if migration_outcome == "commit":
|
|
707
|
+
consecutive_failures = 0
|
|
708
|
+
else:
|
|
709
|
+
if migration_record is not None:
|
|
710
|
+
print(
|
|
711
|
+
"Migration failed: "
|
|
712
|
+
f"{migration_record.target} — {migration_record.summary}"
|
|
713
|
+
)
|
|
714
|
+
consecutive_failures += 1
|
|
715
|
+
if consecutive_failures >= max_consecutive:
|
|
716
|
+
final_status = "max_consecutive_failures"
|
|
717
|
+
raise ContinuousRefactorError(
|
|
718
|
+
f"Stopping: {max_consecutive} consecutive failures"
|
|
719
|
+
)
|
|
720
|
+
_sleep_between_actions(
|
|
721
|
+
sleep_seconds,
|
|
722
|
+
artifacts=artifacts,
|
|
723
|
+
action_index=action_index,
|
|
724
|
+
has_more_actions=(
|
|
725
|
+
source_index < len(targets)
|
|
726
|
+
and _has_action_budget(actions_completed, action_limit)
|
|
727
|
+
),
|
|
728
|
+
)
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
if migration_outcome == "blocked":
|
|
732
|
+
if migration_record is not None:
|
|
733
|
+
print(
|
|
734
|
+
"Migration blocked for human review: "
|
|
735
|
+
f"{migration_record.target} — {migration_record.summary}"
|
|
736
|
+
)
|
|
737
|
+
print("Selecting a target")
|
|
738
|
+
elif migration_record is not None:
|
|
739
|
+
print(
|
|
740
|
+
"No runnable migrations; selecting a target — "
|
|
741
|
+
f"{migration_record.summary}"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
target = targets[source_index]
|
|
745
|
+
source_index += 1
|
|
746
|
+
artifacts.mark_attempt_started(action_index)
|
|
747
|
+
model = target.model_override or args.model
|
|
748
|
+
target_effort_budget, effort_resolution = _target_effort_budget(
|
|
749
|
+
base_effort_budget,
|
|
750
|
+
target,
|
|
751
|
+
)
|
|
752
|
+
effort = target_effort_budget.default_effort
|
|
753
|
+
effort_metadata = effort_resolution.event_fields()
|
|
754
|
+
_log_effort_resolution(
|
|
755
|
+
artifacts,
|
|
756
|
+
effort_resolution,
|
|
757
|
+
attempt=action_index,
|
|
758
|
+
target=target.description,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
print(f"Target: {target.description}")
|
|
762
|
+
route_result = routing_pipeline.route_and_run(
|
|
763
|
+
target,
|
|
764
|
+
taste,
|
|
765
|
+
repo_root,
|
|
766
|
+
artifacts,
|
|
767
|
+
live_dir=live_dir,
|
|
768
|
+
agent=args.agent,
|
|
769
|
+
model=model,
|
|
770
|
+
effort=effort,
|
|
771
|
+
effort_budget=target_effort_budget,
|
|
772
|
+
effort_metadata=effort_metadata,
|
|
773
|
+
timeout=timeout,
|
|
774
|
+
commit_message_prefix=args.commit_message_prefix,
|
|
775
|
+
validation_command=args.validation_command,
|
|
776
|
+
max_attempts=max_attempts_effective,
|
|
777
|
+
attempt=action_index,
|
|
778
|
+
finalize_commit=_finalize_commit,
|
|
779
|
+
check_migrations=False,
|
|
780
|
+
)
|
|
781
|
+
target = route_result.target
|
|
782
|
+
if route_result.outcome == "commit":
|
|
783
|
+
if route_result.decision_record is not None:
|
|
784
|
+
persist_decision(
|
|
785
|
+
repo_root,
|
|
786
|
+
artifacts,
|
|
787
|
+
attempt=action_index,
|
|
788
|
+
retry=route_result.decision_record.retry_used,
|
|
789
|
+
validation_command=args.validation_command,
|
|
790
|
+
record=route_result.decision_record,
|
|
791
|
+
)
|
|
792
|
+
consecutive_failures = 0
|
|
793
|
+
actions_completed += 1
|
|
794
|
+
_sleep_between_actions(
|
|
795
|
+
sleep_seconds,
|
|
796
|
+
artifacts=artifacts,
|
|
797
|
+
action_index=action_index,
|
|
798
|
+
has_more_actions=(
|
|
799
|
+
source_index < len(targets)
|
|
800
|
+
and _has_action_budget(actions_completed, action_limit)
|
|
801
|
+
),
|
|
802
|
+
)
|
|
803
|
+
continue
|
|
804
|
+
if route_result.outcome in {"abandon", "blocked"}:
|
|
805
|
+
if route_result.decision_record is not None:
|
|
806
|
+
persist_decision(
|
|
807
|
+
repo_root,
|
|
808
|
+
artifacts,
|
|
809
|
+
attempt=action_index,
|
|
810
|
+
retry=route_result.decision_record.retry_used,
|
|
811
|
+
validation_command=args.validation_command,
|
|
812
|
+
record=route_result.decision_record,
|
|
813
|
+
)
|
|
814
|
+
actions_completed += 1
|
|
815
|
+
consecutive_failures += 1
|
|
816
|
+
if consecutive_failures >= max_consecutive:
|
|
817
|
+
final_status = "max_consecutive_failures"
|
|
818
|
+
raise ContinuousRefactorError(
|
|
819
|
+
f"Stopping: {max_consecutive} consecutive failures"
|
|
820
|
+
)
|
|
821
|
+
_sleep_between_actions(
|
|
822
|
+
sleep_seconds,
|
|
823
|
+
artifacts=artifacts,
|
|
824
|
+
action_index=action_index,
|
|
825
|
+
has_more_actions=(
|
|
826
|
+
source_index < len(targets)
|
|
827
|
+
and _has_action_budget(actions_completed, action_limit)
|
|
828
|
+
),
|
|
829
|
+
)
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
retry_context: str | None = None
|
|
833
|
+
outcome_record: DecisionRecord | None = None
|
|
834
|
+
retry = 0
|
|
835
|
+
preserved_workspace = _preserve_workspace_tree(repo_root, live_dir)
|
|
836
|
+
|
|
837
|
+
while True:
|
|
838
|
+
retry += 1
|
|
839
|
+
prompt = compose_full_prompt(
|
|
840
|
+
base_prompt=base_prompt,
|
|
841
|
+
taste=taste,
|
|
842
|
+
target=target,
|
|
843
|
+
scope_instruction=args.scope_instruction,
|
|
844
|
+
validation_command=args.validation_command,
|
|
845
|
+
attempt=retry,
|
|
846
|
+
retry_context=retry_context,
|
|
847
|
+
fix_amendment=fix_amendment_text if retry > 1 else None,
|
|
848
|
+
)
|
|
849
|
+
refactor_attempts_module.maybe_run_agent = maybe_run_agent
|
|
850
|
+
refactor_attempts_module.run_tests = run_tests
|
|
851
|
+
record = _run_refactor_attempt(
|
|
852
|
+
repo_root=repo_root,
|
|
853
|
+
artifacts=artifacts,
|
|
854
|
+
target=target,
|
|
855
|
+
attempt=action_index,
|
|
856
|
+
retry=retry,
|
|
857
|
+
agent=args.agent,
|
|
858
|
+
model=model,
|
|
859
|
+
effort=effort,
|
|
860
|
+
effort_metadata=effort_metadata,
|
|
861
|
+
prompt=prompt,
|
|
862
|
+
timeout=timeout,
|
|
863
|
+
validation_command=args.validation_command,
|
|
864
|
+
show_agent_logs=args.show_agent_logs,
|
|
865
|
+
show_command_logs=args.show_command_logs,
|
|
866
|
+
commit_message_prefix=args.commit_message_prefix,
|
|
867
|
+
preserved_workspace=preserved_workspace,
|
|
868
|
+
)
|
|
869
|
+
record_to_persist = effective_record(
|
|
870
|
+
record,
|
|
871
|
+
retry=retry,
|
|
872
|
+
max_attempts=max_attempts_effective,
|
|
873
|
+
)
|
|
874
|
+
if (
|
|
875
|
+
record.decision == "retry"
|
|
876
|
+
and record_to_persist.decision == "abandon"
|
|
877
|
+
and max_attempts_effective is not None
|
|
878
|
+
):
|
|
879
|
+
artifacts.log(
|
|
880
|
+
"WARN",
|
|
881
|
+
f"Exhausted {max_attempts_effective} attempts: {target.description}",
|
|
882
|
+
event="max_attempts_exhausted",
|
|
883
|
+
attempt=action_index,
|
|
884
|
+
retry=retry,
|
|
885
|
+
target=target.description,
|
|
886
|
+
call_role=record.call_role,
|
|
887
|
+
)
|
|
888
|
+
persist_decision(
|
|
889
|
+
repo_root,
|
|
890
|
+
artifacts,
|
|
891
|
+
attempt=action_index,
|
|
892
|
+
retry=retry,
|
|
893
|
+
validation_command=args.validation_command,
|
|
894
|
+
record=record_to_persist,
|
|
895
|
+
)
|
|
896
|
+
outcome_record = record_to_persist
|
|
897
|
+
if record_to_persist.decision == "retry":
|
|
898
|
+
retry_context = _retry_context(record_to_persist)
|
|
899
|
+
continue
|
|
900
|
+
break
|
|
901
|
+
|
|
902
|
+
if outcome_record is not None and outcome_record.decision == "commit":
|
|
903
|
+
consecutive_failures = 0
|
|
904
|
+
else:
|
|
905
|
+
consecutive_failures += 1
|
|
906
|
+
if consecutive_failures >= max_consecutive:
|
|
907
|
+
final_status = "max_consecutive_failures"
|
|
908
|
+
raise ContinuousRefactorError(
|
|
909
|
+
f"Stopping: {max_consecutive} consecutive failures"
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
actions_completed += 1
|
|
913
|
+
_sleep_between_actions(
|
|
914
|
+
sleep_seconds,
|
|
915
|
+
artifacts=artifacts,
|
|
916
|
+
action_index=action_index,
|
|
917
|
+
has_more_actions=(
|
|
918
|
+
source_index < len(targets)
|
|
919
|
+
and _has_action_budget(actions_completed, action_limit)
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
final_status = "completed"
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
except ContinuousRefactorError as error:
|
|
927
|
+
if final_status == "running":
|
|
928
|
+
final_status = "failed"
|
|
929
|
+
error_message = str(error)
|
|
930
|
+
raise
|
|
931
|
+
except KeyboardInterrupt:
|
|
932
|
+
final_status = "interrupted"
|
|
933
|
+
artifacts.log("WARN", "Interrupted", event="interrupted")
|
|
934
|
+
discard_workspace_changes(repo_root)
|
|
935
|
+
print(f"\nArtifact logs: {artifacts.root}", file=sys.stderr)
|
|
936
|
+
return 130
|
|
937
|
+
finally:
|
|
938
|
+
artifacts.finish(final_status, error_message=error_message)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _focus_eligible_manifests(
|
|
942
|
+
live_dir: Path, now: datetime, effort_budget: EffortBudget,
|
|
943
|
+
) -> list[tuple[MigrationManifest, Path]]:
|
|
944
|
+
return [
|
|
945
|
+
pair for pair in migration_tick.enumerate_eligible_manifests(
|
|
946
|
+
live_dir,
|
|
947
|
+
now,
|
|
948
|
+
effort_budget,
|
|
949
|
+
)
|
|
950
|
+
if not pair[0].awaiting_human_review
|
|
951
|
+
]
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _eligible_phase_path_labels(
|
|
955
|
+
repo_root: Path,
|
|
956
|
+
candidates: list[tuple[MigrationManifest, Path]],
|
|
957
|
+
) -> tuple[str, ...]:
|
|
958
|
+
return tuple(
|
|
959
|
+
_repo_relative_path(
|
|
960
|
+
repo_root,
|
|
961
|
+
manifest_path.parent
|
|
962
|
+
/ phase_file_reference(resolve_current_phase(manifest)),
|
|
963
|
+
)
|
|
964
|
+
for manifest, manifest_path in candidates
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _repo_relative_path(repo_root: Path, path: Path) -> str:
|
|
969
|
+
resolved_root = repo_root.resolve()
|
|
970
|
+
resolved_path = path.resolve()
|
|
971
|
+
try:
|
|
972
|
+
return resolved_path.relative_to(resolved_root).as_posix()
|
|
973
|
+
except ValueError:
|
|
974
|
+
return os.path.relpath(resolved_path, resolved_root).replace(os.sep, "/")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def run_migrations_focused_loop(args: argparse.Namespace) -> int:
|
|
978
|
+
repo_root = args.repo_root.resolve()
|
|
979
|
+
timeout = args.timeout or 1800
|
|
980
|
+
sleep_seconds = getattr(args, "sleep", 0.0)
|
|
981
|
+
max_consecutive = args.max_consecutive_failures
|
|
982
|
+
base_effort_budget = _effort_budget_from_args(args)
|
|
983
|
+
max_attempts_effective = _effective_max_attempts(
|
|
984
|
+
getattr(args, "max_attempts", None)
|
|
985
|
+
)
|
|
986
|
+
taste = _load_taste_safe(repo_root)
|
|
987
|
+
|
|
988
|
+
live_dir = _resolve_live_migrations_dir(repo_root)
|
|
989
|
+
if live_dir is None:
|
|
990
|
+
raise ContinuousRefactorError(
|
|
991
|
+
"no live-migrations-dir configured for this project; "
|
|
992
|
+
"run `continuous-refactoring init --live-migrations-dir <dir>` first."
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
artifacts = create_run_artifacts(
|
|
996
|
+
repo_root,
|
|
997
|
+
agent=args.agent,
|
|
998
|
+
model=args.model,
|
|
999
|
+
effort=base_effort_budget.default_effort,
|
|
1000
|
+
default_effort=base_effort_budget.default_effort,
|
|
1001
|
+
max_allowed_effort=base_effort_budget.max_allowed_effort,
|
|
1002
|
+
test_command=args.validation_command,
|
|
1003
|
+
)
|
|
1004
|
+
artifacts.log("INFO", f"run artifacts: {artifacts.root}", event="artifacts_ready")
|
|
1005
|
+
_log_effort_budget(artifacts, base_effort_budget)
|
|
1006
|
+
artifacts.log(
|
|
1007
|
+
"INFO",
|
|
1008
|
+
f"focus-on-live-migrations: {live_dir}",
|
|
1009
|
+
event="focus_on_live_migrations",
|
|
1010
|
+
)
|
|
1011
|
+
if max_attempts_effective is None:
|
|
1012
|
+
artifacts.log(
|
|
1013
|
+
"WARN",
|
|
1014
|
+
"max_attempts=0: unlimited retries; permanently-broken targets will not exit",
|
|
1015
|
+
event="max_attempts_unlimited",
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
final_status = "running"
|
|
1019
|
+
error_message: str | None = None
|
|
1020
|
+
consecutive_failures = 0
|
|
1021
|
+
iteration = 0
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
require_clean_worktree(repo_root)
|
|
1025
|
+
|
|
1026
|
+
baseline_ok, baseline_context = run_baseline_checks(
|
|
1027
|
+
args.validation_command,
|
|
1028
|
+
repo_root,
|
|
1029
|
+
stdout_path=artifacts.baseline_dir("initial") / "tests.stdout.log",
|
|
1030
|
+
stderr_path=artifacts.baseline_dir("initial") / "tests.stderr.log",
|
|
1031
|
+
)
|
|
1032
|
+
if not baseline_ok:
|
|
1033
|
+
final_status = "baseline_failed"
|
|
1034
|
+
raise ContinuousRefactorError(
|
|
1035
|
+
f"Baseline validation failed\n{baseline_context}"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
while True:
|
|
1039
|
+
now = datetime.now(timezone.utc)
|
|
1040
|
+
eligible = _focus_eligible_manifests(live_dir, now, base_effort_budget)
|
|
1041
|
+
if not eligible:
|
|
1042
|
+
print(
|
|
1043
|
+
"Focused migrations loop: nothing eligible — "
|
|
1044
|
+
"every migration is done or blocked."
|
|
1045
|
+
)
|
|
1046
|
+
artifacts.log(
|
|
1047
|
+
"INFO",
|
|
1048
|
+
"No eligible migrations remain; terminating.",
|
|
1049
|
+
event="focus_eligible_empty",
|
|
1050
|
+
)
|
|
1051
|
+
final_status = "completed"
|
|
1052
|
+
return 0
|
|
1053
|
+
|
|
1054
|
+
iteration += 1
|
|
1055
|
+
artifacts.mark_attempt_started(iteration)
|
|
1056
|
+
names = ", ".join(_eligible_phase_path_labels(repo_root, eligible))
|
|
1057
|
+
print(f"\n── Migration tick {iteration} (eligible: {names}) ──")
|
|
1058
|
+
|
|
1059
|
+
outcome, record = migration_tick.try_migration_tick(
|
|
1060
|
+
live_dir,
|
|
1061
|
+
taste,
|
|
1062
|
+
repo_root,
|
|
1063
|
+
artifacts,
|
|
1064
|
+
agent=args.agent,
|
|
1065
|
+
model=args.model,
|
|
1066
|
+
effort=base_effort_budget.default_effort,
|
|
1067
|
+
effort_budget=base_effort_budget,
|
|
1068
|
+
timeout=timeout,
|
|
1069
|
+
commit_message_prefix=args.commit_message_prefix,
|
|
1070
|
+
validation_command=args.validation_command,
|
|
1071
|
+
max_attempts=max_attempts_effective,
|
|
1072
|
+
attempt=iteration,
|
|
1073
|
+
finalize_commit=_finalize_commit,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
if record is not None and outcome != "not-routed":
|
|
1077
|
+
persist_decision(
|
|
1078
|
+
repo_root,
|
|
1079
|
+
artifacts,
|
|
1080
|
+
attempt=iteration,
|
|
1081
|
+
retry=record.retry_used,
|
|
1082
|
+
validation_command=args.validation_command,
|
|
1083
|
+
record=record,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
if outcome == "commit":
|
|
1087
|
+
consecutive_failures = 0
|
|
1088
|
+
elif outcome in {"abandon", "blocked"}:
|
|
1089
|
+
consecutive_failures += 1
|
|
1090
|
+
if consecutive_failures >= max_consecutive:
|
|
1091
|
+
final_status = "max_consecutive_failures"
|
|
1092
|
+
raise ContinuousRefactorError(
|
|
1093
|
+
f"Stopping: {max_consecutive} consecutive failures"
|
|
1094
|
+
)
|
|
1095
|
+
else:
|
|
1096
|
+
message = (
|
|
1097
|
+
"Migration tick deferred all eligible migrations; "
|
|
1098
|
+
"terminating until a wake-up window or manifest change."
|
|
1099
|
+
)
|
|
1100
|
+
if record is not None:
|
|
1101
|
+
message = (
|
|
1102
|
+
"Migration tick deferred all eligible migrations: "
|
|
1103
|
+
f"{record.summary}"
|
|
1104
|
+
)
|
|
1105
|
+
artifacts.log(
|
|
1106
|
+
"INFO",
|
|
1107
|
+
message,
|
|
1108
|
+
event="focus_tick_deferred",
|
|
1109
|
+
)
|
|
1110
|
+
print(message)
|
|
1111
|
+
final_status = "completed"
|
|
1112
|
+
return 0
|
|
1113
|
+
|
|
1114
|
+
if sleep_seconds > 0:
|
|
1115
|
+
artifacts.log(
|
|
1116
|
+
"INFO",
|
|
1117
|
+
f"Sleeping {sleep_seconds:g}s before next tick",
|
|
1118
|
+
event="sleep_between_ticks",
|
|
1119
|
+
attempt=iteration,
|
|
1120
|
+
sleep_seconds=sleep_seconds,
|
|
1121
|
+
)
|
|
1122
|
+
print(f"Sleeping {sleep_seconds:g}s before next tick")
|
|
1123
|
+
time.sleep(sleep_seconds)
|
|
1124
|
+
|
|
1125
|
+
except ContinuousRefactorError as error:
|
|
1126
|
+
if final_status == "running":
|
|
1127
|
+
final_status = "failed"
|
|
1128
|
+
error_message = str(error)
|
|
1129
|
+
raise
|
|
1130
|
+
except KeyboardInterrupt:
|
|
1131
|
+
final_status = "interrupted"
|
|
1132
|
+
artifacts.log("WARN", "Interrupted", event="interrupted")
|
|
1133
|
+
discard_workspace_changes(repo_root)
|
|
1134
|
+
print(f"\nArtifact logs: {artifacts.root}", file=sys.stderr)
|
|
1135
|
+
return 130
|
|
1136
|
+
finally:
|
|
1137
|
+
artifacts.finish(final_status, error_message=error_message)
|