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.
@@ -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)