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,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from continuous_refactoring.artifacts import ContinuousRefactorError
|
|
8
|
+
from continuous_refactoring.effort import EffortTier, require_effort_tier
|
|
9
|
+
from continuous_refactoring.migrations import (
|
|
10
|
+
MIGRATION_STATUSES,
|
|
11
|
+
MigrationManifest,
|
|
12
|
+
MigrationStatus,
|
|
13
|
+
PhaseSpec,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = ("decode_manifest_payload", "encode_manifest_payload")
|
|
17
|
+
|
|
18
|
+
_VALID_STATUSES: frozenset[str] = frozenset(
|
|
19
|
+
cast(tuple[str, ...], MIGRATION_STATUSES)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_status(raw_status: object) -> MigrationStatus:
|
|
24
|
+
if not isinstance(raw_status, str):
|
|
25
|
+
raise ContinuousRefactorError(
|
|
26
|
+
f"Migration status must be a string: {raw_status!r}"
|
|
27
|
+
)
|
|
28
|
+
if raw_status not in _VALID_STATUSES:
|
|
29
|
+
raise ContinuousRefactorError(f"Unknown migration status: {raw_status!r}")
|
|
30
|
+
return cast(MigrationStatus, raw_status)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_mapping(value: object, *, field: str) -> dict[str, object]:
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise ContinuousRefactorError(
|
|
36
|
+
f"Migration field {field!r} must be an object: {value!r}"
|
|
37
|
+
)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_str(value: object, *, field: str) -> str:
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
raise ContinuousRefactorError(
|
|
44
|
+
f"Migration field {field!r} must be a string: {value!r}"
|
|
45
|
+
)
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _optional_str(value: object, *, field: str) -> str | None:
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
return _require_str(value, field=field)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_bool(value: object, *, field: str) -> bool:
|
|
56
|
+
if not isinstance(value, bool):
|
|
57
|
+
raise ContinuousRefactorError(
|
|
58
|
+
f"Migration field {field!r} must be a boolean: {value!r}"
|
|
59
|
+
)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_unique_phase_names(phases: tuple[PhaseSpec, ...]) -> None:
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
duplicates: list[str] = []
|
|
66
|
+
for phase in phases:
|
|
67
|
+
if phase.name in seen:
|
|
68
|
+
duplicates.append(phase.name)
|
|
69
|
+
continue
|
|
70
|
+
seen.add(phase.name)
|
|
71
|
+
if duplicates:
|
|
72
|
+
repeated = ", ".join(sorted(set(duplicates)))
|
|
73
|
+
raise ContinuousRefactorError(
|
|
74
|
+
f"Duplicate phase names are not allowed: {repeated}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _require_phase_precondition(phase: dict[str, object], *, prefix: str) -> str:
|
|
79
|
+
if "precondition" in phase:
|
|
80
|
+
return _require_str(phase.get("precondition"), field=f"{prefix}.precondition")
|
|
81
|
+
if "ready_when" in phase:
|
|
82
|
+
return _require_str(phase.get("ready_when"), field=f"{prefix}.ready_when")
|
|
83
|
+
raise ContinuousRefactorError(
|
|
84
|
+
f"Migration field {prefix!r} must include 'precondition' "
|
|
85
|
+
"(or legacy 'ready_when')"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _require_phase(raw_phase: object, *, index: int) -> PhaseSpec:
|
|
90
|
+
phase = _require_mapping(raw_phase, field=f"phases[{index}]")
|
|
91
|
+
prefix = f"phases[{index}]"
|
|
92
|
+
required_effort_raw = phase.get("required_effort")
|
|
93
|
+
required_effort: EffortTier | None = None
|
|
94
|
+
if required_effort_raw is not None:
|
|
95
|
+
required_effort = require_effort_tier(
|
|
96
|
+
required_effort_raw,
|
|
97
|
+
field=f"{prefix}.required_effort",
|
|
98
|
+
)
|
|
99
|
+
return PhaseSpec(
|
|
100
|
+
name=_require_str(phase.get("name"), field=f"{prefix}.name"),
|
|
101
|
+
file=_require_str(phase.get("file"), field=f"{prefix}.file"),
|
|
102
|
+
done=_require_bool(phase.get("done"), field=f"{prefix}.done"),
|
|
103
|
+
precondition=_require_phase_precondition(phase, prefix=prefix),
|
|
104
|
+
required_effort=required_effort,
|
|
105
|
+
effort_reason=_optional_str(
|
|
106
|
+
phase.get("effort_reason"), field=f"{prefix}.effort_reason"
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _require_phases(raw_phases: object) -> tuple[PhaseSpec, ...]:
|
|
112
|
+
if raw_phases is None:
|
|
113
|
+
return ()
|
|
114
|
+
if not isinstance(raw_phases, list):
|
|
115
|
+
raise ContinuousRefactorError(
|
|
116
|
+
f"Migration field 'phases' must be a list: {raw_phases!r}"
|
|
117
|
+
)
|
|
118
|
+
phases = tuple(
|
|
119
|
+
_require_phase(raw_phase, index=index)
|
|
120
|
+
for index, raw_phase in enumerate(raw_phases)
|
|
121
|
+
)
|
|
122
|
+
_require_unique_phase_names(phases)
|
|
123
|
+
return phases
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _legacy_current_phase_name(
|
|
127
|
+
legacy_cursor: int, phases: tuple[PhaseSpec, ...],
|
|
128
|
+
) -> str:
|
|
129
|
+
if not phases:
|
|
130
|
+
return ""
|
|
131
|
+
if 0 <= legacy_cursor < len(phases):
|
|
132
|
+
return phases[legacy_cursor].name
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _require_current_phase(value: object, *, phases: tuple[PhaseSpec, ...]) -> str:
|
|
137
|
+
if isinstance(value, bool):
|
|
138
|
+
raise ContinuousRefactorError(
|
|
139
|
+
f"Migration field 'current_phase' must be a string or legacy int: {value!r}"
|
|
140
|
+
)
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
if value == "":
|
|
143
|
+
return ""
|
|
144
|
+
if not any(phase.name == value for phase in phases):
|
|
145
|
+
raise ContinuousRefactorError(
|
|
146
|
+
f"Migration field 'current_phase' names an unknown phase: {value!r}"
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
if isinstance(value, int):
|
|
150
|
+
return _legacy_current_phase_name(value, phases)
|
|
151
|
+
raise ContinuousRefactorError(
|
|
152
|
+
f"Migration field 'current_phase' must be a string or legacy int: {value!r}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def decode_manifest_payload(raw_payload: object) -> MigrationManifest:
|
|
157
|
+
raw = _require_mapping(raw_payload, field="manifest")
|
|
158
|
+
phases = _require_phases(raw.get("phases"))
|
|
159
|
+
return MigrationManifest(
|
|
160
|
+
name=_require_str(raw.get("name"), field="name"),
|
|
161
|
+
created_at=_require_str(raw.get("created_at"), field="created_at"),
|
|
162
|
+
last_touch=_require_str(raw.get("last_touch"), field="last_touch"),
|
|
163
|
+
wake_up_on=_optional_str(raw.get("wake_up_on"), field="wake_up_on"),
|
|
164
|
+
awaiting_human_review=_require_bool(
|
|
165
|
+
raw.get("awaiting_human_review", False), field="awaiting_human_review"
|
|
166
|
+
),
|
|
167
|
+
status=_require_status(raw.get("status")),
|
|
168
|
+
current_phase=_require_current_phase(
|
|
169
|
+
raw.get("current_phase"), phases=phases,
|
|
170
|
+
),
|
|
171
|
+
phases=phases,
|
|
172
|
+
human_review_reason=_optional_str(
|
|
173
|
+
raw.get("human_review_reason"), field="human_review_reason"
|
|
174
|
+
),
|
|
175
|
+
cooldown_until=_optional_str(
|
|
176
|
+
raw.get("cooldown_until"), field="cooldown_until"
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def encode_manifest_payload(manifest: MigrationManifest) -> str:
|
|
182
|
+
_require_status(manifest.status)
|
|
183
|
+
_require_unique_phase_names(manifest.phases)
|
|
184
|
+
if manifest.current_phase and not any(
|
|
185
|
+
phase.name == manifest.current_phase for phase in manifest.phases
|
|
186
|
+
):
|
|
187
|
+
raise ContinuousRefactorError(
|
|
188
|
+
f"Cannot save manifest with unknown current phase {manifest.current_phase!r}"
|
|
189
|
+
)
|
|
190
|
+
return json.dumps(asdict(manifest), indent=2, sort_keys=True) + "\n"
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Migration tick orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Protocol
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from continuous_refactoring.artifacts import RunArtifacts
|
|
12
|
+
from continuous_refactoring.migrations import MigrationManifest, PhaseSpec
|
|
13
|
+
from continuous_refactoring.phases import ExecutePhaseOutcome
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"enumerate_eligible_manifests",
|
|
17
|
+
"try_migration_tick",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
from continuous_refactoring.artifacts import ContinuousRefactorError
|
|
21
|
+
from continuous_refactoring.commit_messages import build_commit_message
|
|
22
|
+
from continuous_refactoring.decisions import (
|
|
23
|
+
DecisionRecord,
|
|
24
|
+
RouteOutcome,
|
|
25
|
+
error_failure_kind,
|
|
26
|
+
sanitize_text,
|
|
27
|
+
)
|
|
28
|
+
from continuous_refactoring.effort import (
|
|
29
|
+
EffortBudget,
|
|
30
|
+
effort_exceeds,
|
|
31
|
+
resolve_effort_budget,
|
|
32
|
+
resolve_phase_effort,
|
|
33
|
+
)
|
|
34
|
+
from continuous_refactoring.git import get_head_sha
|
|
35
|
+
from continuous_refactoring.migrations import (
|
|
36
|
+
bump_last_touch,
|
|
37
|
+
eligible_now,
|
|
38
|
+
has_executable_phase,
|
|
39
|
+
load_manifest,
|
|
40
|
+
phase_file_reference,
|
|
41
|
+
resolve_current_phase,
|
|
42
|
+
save_manifest,
|
|
43
|
+
)
|
|
44
|
+
from continuous_refactoring.phases import (
|
|
45
|
+
ReadyVerdict,
|
|
46
|
+
check_phase_ready,
|
|
47
|
+
execute_phase,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_BASELINE_VALIDATION_UNCERTAINTY_PHRASES = (
|
|
52
|
+
"baseline green",
|
|
53
|
+
"baseline validation",
|
|
54
|
+
"current tests pass",
|
|
55
|
+
"fresh test evidence",
|
|
56
|
+
"fresh validation evidence",
|
|
57
|
+
"full test suite passes",
|
|
58
|
+
"tests pass now",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _FinalizeCommit(Protocol):
|
|
63
|
+
def __call__(
|
|
64
|
+
self,
|
|
65
|
+
repo_root: Path,
|
|
66
|
+
head_before: str,
|
|
67
|
+
commit_message: str,
|
|
68
|
+
*,
|
|
69
|
+
artifacts: RunArtifacts,
|
|
70
|
+
attempt: int,
|
|
71
|
+
phase: str,
|
|
72
|
+
) -> str | None:
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def enumerate_eligible_manifests(
|
|
77
|
+
live_dir: Path,
|
|
78
|
+
now: datetime,
|
|
79
|
+
effort_budget: EffortBudget | None = None,
|
|
80
|
+
) -> list[tuple[MigrationManifest, Path]]:
|
|
81
|
+
if not live_dir.is_dir():
|
|
82
|
+
return []
|
|
83
|
+
candidates: list[tuple[MigrationManifest, Path]] = []
|
|
84
|
+
for entry in sorted(live_dir.iterdir()):
|
|
85
|
+
if not entry.is_dir() or entry.name.startswith("__"):
|
|
86
|
+
continue
|
|
87
|
+
manifest_path = entry / "manifest.json"
|
|
88
|
+
if not manifest_path.exists():
|
|
89
|
+
continue
|
|
90
|
+
manifest = load_manifest(manifest_path)
|
|
91
|
+
if manifest.status not in ("ready", "in-progress"):
|
|
92
|
+
continue
|
|
93
|
+
if manifest.awaiting_human_review:
|
|
94
|
+
continue
|
|
95
|
+
if not has_executable_phase(manifest):
|
|
96
|
+
continue
|
|
97
|
+
if not eligible_now(manifest, now):
|
|
98
|
+
continue
|
|
99
|
+
candidates.append((manifest, manifest_path))
|
|
100
|
+
if effort_budget is not None:
|
|
101
|
+
seen_paths = {path for _, path in candidates}
|
|
102
|
+
for manifest, manifest_path in _cooling_effort_candidates(
|
|
103
|
+
live_dir, now, effort_budget,
|
|
104
|
+
):
|
|
105
|
+
if manifest_path not in seen_paths:
|
|
106
|
+
candidates.append((manifest, manifest_path))
|
|
107
|
+
candidates.sort(key=lambda pair: datetime.fromisoformat(pair[0].created_at))
|
|
108
|
+
return candidates
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cooling_effort_candidates(
|
|
112
|
+
live_dir: Path,
|
|
113
|
+
now: datetime,
|
|
114
|
+
budget: EffortBudget,
|
|
115
|
+
) -> list[tuple[MigrationManifest, Path]]:
|
|
116
|
+
if not live_dir.is_dir():
|
|
117
|
+
return []
|
|
118
|
+
candidates: list[tuple[MigrationManifest, Path]] = []
|
|
119
|
+
for entry in sorted(live_dir.iterdir()):
|
|
120
|
+
if not entry.is_dir() or entry.name.startswith("__"):
|
|
121
|
+
continue
|
|
122
|
+
manifest_path = entry / "manifest.json"
|
|
123
|
+
if not manifest_path.exists():
|
|
124
|
+
continue
|
|
125
|
+
manifest = load_manifest(manifest_path)
|
|
126
|
+
if not _can_ignore_effort_cooldown(manifest, now, budget):
|
|
127
|
+
continue
|
|
128
|
+
candidates.append((manifest, manifest_path))
|
|
129
|
+
return candidates
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _can_ignore_effort_cooldown(
|
|
133
|
+
manifest: MigrationManifest,
|
|
134
|
+
now: datetime,
|
|
135
|
+
budget: EffortBudget,
|
|
136
|
+
) -> bool:
|
|
137
|
+
if manifest.status not in ("ready", "in-progress"):
|
|
138
|
+
return False
|
|
139
|
+
if manifest.awaiting_human_review or not has_executable_phase(manifest):
|
|
140
|
+
return False
|
|
141
|
+
if manifest.cooldown_until is None:
|
|
142
|
+
return False
|
|
143
|
+
if datetime.fromisoformat(manifest.cooldown_until) <= now:
|
|
144
|
+
return False
|
|
145
|
+
phase = resolve_current_phase(manifest)
|
|
146
|
+
return (
|
|
147
|
+
phase.required_effort is not None
|
|
148
|
+
and not effort_exceeds(phase.required_effort, budget.max_allowed_effort)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def try_migration_tick(
|
|
153
|
+
live_dir: Path,
|
|
154
|
+
taste: str,
|
|
155
|
+
repo_root: Path,
|
|
156
|
+
artifacts: RunArtifacts,
|
|
157
|
+
*,
|
|
158
|
+
agent: str,
|
|
159
|
+
model: str,
|
|
160
|
+
effort: str,
|
|
161
|
+
timeout: int | None,
|
|
162
|
+
commit_message_prefix: str,
|
|
163
|
+
validation_command: str,
|
|
164
|
+
max_attempts: int | None,
|
|
165
|
+
attempt: int,
|
|
166
|
+
finalize_commit: _FinalizeCommit,
|
|
167
|
+
effort_budget: EffortBudget | None = None,
|
|
168
|
+
) -> tuple[RouteOutcome, DecisionRecord | None]:
|
|
169
|
+
resolved_budget = effort_budget or resolve_effort_budget(effort, None)
|
|
170
|
+
now = datetime.now(timezone.utc)
|
|
171
|
+
candidates = enumerate_eligible_manifests(live_dir, now, resolved_budget)
|
|
172
|
+
deferred_record: DecisionRecord | None = None
|
|
173
|
+
pending_defers: list[tuple[MigrationManifest, Path]] = []
|
|
174
|
+
|
|
175
|
+
for manifest, manifest_path in candidates:
|
|
176
|
+
phase = resolve_current_phase(manifest)
|
|
177
|
+
target_label = _target_label(manifest, phase)
|
|
178
|
+
if (
|
|
179
|
+
phase.required_effort is not None
|
|
180
|
+
and effort_exceeds(
|
|
181
|
+
phase.required_effort,
|
|
182
|
+
resolved_budget.max_allowed_effort,
|
|
183
|
+
)
|
|
184
|
+
):
|
|
185
|
+
reason = _effort_defer_reason(
|
|
186
|
+
phase,
|
|
187
|
+
max_allowed_effort=resolved_budget.max_allowed_effort,
|
|
188
|
+
)
|
|
189
|
+
_log_phase_effort_deferred(
|
|
190
|
+
artifacts,
|
|
191
|
+
target_label,
|
|
192
|
+
phase,
|
|
193
|
+
reason,
|
|
194
|
+
max_allowed_effort=resolved_budget.max_allowed_effort,
|
|
195
|
+
)
|
|
196
|
+
pending_defers.append(
|
|
197
|
+
(
|
|
198
|
+
_defer_manifest(
|
|
199
|
+
manifest,
|
|
200
|
+
now,
|
|
201
|
+
verdict="effort-over-budget",
|
|
202
|
+
reason=reason,
|
|
203
|
+
),
|
|
204
|
+
manifest_path,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
deferred_record = _effort_deferred_record(reason, repo_root, target_label)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
phase_resolution = resolve_phase_effort(
|
|
211
|
+
resolved_budget,
|
|
212
|
+
phase.required_effort,
|
|
213
|
+
reason=phase.effort_reason,
|
|
214
|
+
)
|
|
215
|
+
phase_effort = phase_resolution.effective_effort
|
|
216
|
+
effort_metadata = phase_resolution.event_fields()
|
|
217
|
+
try:
|
|
218
|
+
verdict, reason = check_phase_ready(
|
|
219
|
+
phase,
|
|
220
|
+
manifest,
|
|
221
|
+
repo_root,
|
|
222
|
+
artifacts,
|
|
223
|
+
taste=taste,
|
|
224
|
+
attempt=attempt,
|
|
225
|
+
retry=1,
|
|
226
|
+
agent=agent,
|
|
227
|
+
model=model,
|
|
228
|
+
effort=phase_effort,
|
|
229
|
+
effort_metadata=effort_metadata,
|
|
230
|
+
timeout=timeout,
|
|
231
|
+
)
|
|
232
|
+
except ContinuousRefactorError as error:
|
|
233
|
+
return "abandon", _ready_check_failure_record(error, repo_root, target_label)
|
|
234
|
+
|
|
235
|
+
verdict, reason = _normalize_ready_verdict(verdict, reason)
|
|
236
|
+
|
|
237
|
+
if verdict == "yes":
|
|
238
|
+
head_before = get_head_sha(repo_root)
|
|
239
|
+
outcome = execute_phase(
|
|
240
|
+
phase,
|
|
241
|
+
manifest,
|
|
242
|
+
taste,
|
|
243
|
+
repo_root,
|
|
244
|
+
live_dir,
|
|
245
|
+
artifacts,
|
|
246
|
+
attempt=attempt,
|
|
247
|
+
retry=1,
|
|
248
|
+
agent=agent,
|
|
249
|
+
model=model,
|
|
250
|
+
effort=phase_effort,
|
|
251
|
+
effort_metadata=effort_metadata,
|
|
252
|
+
timeout=timeout,
|
|
253
|
+
validation_command=validation_command,
|
|
254
|
+
max_attempts=max_attempts,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if outcome.status != "failed":
|
|
258
|
+
finalize_commit(
|
|
259
|
+
repo_root,
|
|
260
|
+
head_before,
|
|
261
|
+
build_commit_message(
|
|
262
|
+
f"{commit_message_prefix}: migration/{manifest.name}"
|
|
263
|
+
f"/{phase_file_reference(phase)}",
|
|
264
|
+
why=sanitize_text(outcome.reason, repo_root) or outcome.reason,
|
|
265
|
+
validation=validation_command,
|
|
266
|
+
),
|
|
267
|
+
artifacts=artifacts,
|
|
268
|
+
attempt=attempt,
|
|
269
|
+
phase="migration",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
print(
|
|
273
|
+
f"Migration: {outcome.status}"
|
|
274
|
+
f" — {manifest.name} {phase_file_reference(phase)} ({phase.name})"
|
|
275
|
+
)
|
|
276
|
+
if outcome.status == "failed":
|
|
277
|
+
return "abandon", _phase_failure_record(outcome, repo_root, target_label)
|
|
278
|
+
return "commit", _phase_commit_record(outcome, repo_root, target_label)
|
|
279
|
+
|
|
280
|
+
pending_defers.append(
|
|
281
|
+
(
|
|
282
|
+
_defer_manifest(manifest, now, verdict=verdict, reason=reason),
|
|
283
|
+
manifest_path,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
if verdict == "unverifiable":
|
|
287
|
+
_save_pending_defers(pending_defers)
|
|
288
|
+
return "blocked", _human_review_record(reason, repo_root, target_label)
|
|
289
|
+
deferred_record = _deferred_record(reason, repo_root, target_label)
|
|
290
|
+
|
|
291
|
+
_save_pending_defers(pending_defers)
|
|
292
|
+
return "not-routed", deferred_record
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _target_label(manifest: MigrationManifest, phase: PhaseSpec) -> str:
|
|
296
|
+
return f"{manifest.name} {phase_file_reference(phase)} ({phase.name})"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _normalize_ready_verdict(
|
|
300
|
+
verdict: ReadyVerdict,
|
|
301
|
+
reason: str,
|
|
302
|
+
) -> tuple[ReadyVerdict, str]:
|
|
303
|
+
if verdict != "unverifiable":
|
|
304
|
+
return verdict, reason
|
|
305
|
+
if not _is_baseline_validation_uncertainty(reason):
|
|
306
|
+
return verdict, reason
|
|
307
|
+
return "no", reason
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_baseline_validation_uncertainty(reason: str) -> bool:
|
|
311
|
+
reason_lower = reason.lower()
|
|
312
|
+
return any(
|
|
313
|
+
phrase in reason_lower
|
|
314
|
+
for phrase in _BASELINE_VALIDATION_UNCERTAINTY_PHRASES
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _save_pending_defers(
|
|
319
|
+
pending_defers: list[tuple[MigrationManifest, Path]],
|
|
320
|
+
) -> None:
|
|
321
|
+
for deferred_manifest, manifest_path in pending_defers:
|
|
322
|
+
save_manifest(deferred_manifest, manifest_path)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _effort_defer_reason(
|
|
326
|
+
phase: PhaseSpec,
|
|
327
|
+
*,
|
|
328
|
+
max_allowed_effort: str,
|
|
329
|
+
) -> str:
|
|
330
|
+
detail = (
|
|
331
|
+
f" Reason: {phase.effort_reason}."
|
|
332
|
+
if phase.effort_reason
|
|
333
|
+
else ""
|
|
334
|
+
)
|
|
335
|
+
return (
|
|
336
|
+
f"Phase requires {phase.required_effort} effort, above this run's "
|
|
337
|
+
f"max allowed effort {max_allowed_effort}.{detail}"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _log_phase_effort_deferred(
|
|
342
|
+
artifacts: RunArtifacts,
|
|
343
|
+
target_label: str,
|
|
344
|
+
phase: PhaseSpec,
|
|
345
|
+
reason: str,
|
|
346
|
+
*,
|
|
347
|
+
max_allowed_effort: str,
|
|
348
|
+
) -> None:
|
|
349
|
+
artifacts.log(
|
|
350
|
+
"INFO",
|
|
351
|
+
f"phase effort deferred: {target_label}",
|
|
352
|
+
event="phase_effort_deferred",
|
|
353
|
+
target=target_label,
|
|
354
|
+
required_effort=phase.required_effort,
|
|
355
|
+
max_allowed_effort=max_allowed_effort,
|
|
356
|
+
effort_reason=phase.effort_reason,
|
|
357
|
+
summary=reason,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _ready_check_failure_record(
|
|
362
|
+
error: ContinuousRefactorError, repo_root: Path, target_label: str,
|
|
363
|
+
) -> DecisionRecord:
|
|
364
|
+
summary = sanitize_text(str(error), repo_root) or str(error)
|
|
365
|
+
return DecisionRecord(
|
|
366
|
+
decision="abandon",
|
|
367
|
+
retry_recommendation="new-target",
|
|
368
|
+
target=target_label,
|
|
369
|
+
call_role="phase.ready-check",
|
|
370
|
+
phase_reached="phase.ready-check",
|
|
371
|
+
failure_kind=error_failure_kind(str(error)),
|
|
372
|
+
summary=summary,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _phase_failure_record(
|
|
377
|
+
outcome: ExecutePhaseOutcome, repo_root: Path, target_label: str,
|
|
378
|
+
) -> DecisionRecord:
|
|
379
|
+
return DecisionRecord(
|
|
380
|
+
decision="abandon",
|
|
381
|
+
retry_recommendation="new-target",
|
|
382
|
+
target=target_label,
|
|
383
|
+
call_role=outcome.call_role or "phase.execute",
|
|
384
|
+
phase_reached=outcome.phase_reached or "phase.execute",
|
|
385
|
+
failure_kind=outcome.failure_kind or "phase-failed",
|
|
386
|
+
summary=sanitize_text(outcome.reason, repo_root) or outcome.reason,
|
|
387
|
+
retry_used=outcome.retry,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _phase_commit_record(
|
|
392
|
+
outcome: ExecutePhaseOutcome,
|
|
393
|
+
repo_root: Path,
|
|
394
|
+
target_label: str,
|
|
395
|
+
) -> DecisionRecord:
|
|
396
|
+
return DecisionRecord(
|
|
397
|
+
decision="commit",
|
|
398
|
+
retry_recommendation="none",
|
|
399
|
+
target=target_label,
|
|
400
|
+
call_role="phase.execute",
|
|
401
|
+
phase_reached="phase.execute",
|
|
402
|
+
failure_kind="none",
|
|
403
|
+
summary=sanitize_text(outcome.reason, repo_root) or outcome.reason,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _defer_manifest(
|
|
408
|
+
manifest: MigrationManifest,
|
|
409
|
+
now: datetime,
|
|
410
|
+
*,
|
|
411
|
+
verdict: str,
|
|
412
|
+
reason: str,
|
|
413
|
+
) -> MigrationManifest:
|
|
414
|
+
updated = replace(
|
|
415
|
+
bump_last_touch(manifest, now),
|
|
416
|
+
cooldown_until=(now + timedelta(hours=6)).isoformat(timespec="milliseconds"),
|
|
417
|
+
)
|
|
418
|
+
if updated.wake_up_on is None:
|
|
419
|
+
wake = (now + timedelta(days=7)).isoformat(timespec="milliseconds")
|
|
420
|
+
updated = replace(updated, wake_up_on=wake)
|
|
421
|
+
if verdict == "unverifiable":
|
|
422
|
+
updated = replace(
|
|
423
|
+
updated,
|
|
424
|
+
awaiting_human_review=True,
|
|
425
|
+
human_review_reason=reason,
|
|
426
|
+
)
|
|
427
|
+
return updated
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _human_review_record(
|
|
431
|
+
reason: str, repo_root: Path, target_label: str,
|
|
432
|
+
) -> DecisionRecord:
|
|
433
|
+
summary = sanitize_text(reason, repo_root) or "Phase requires human review"
|
|
434
|
+
return DecisionRecord(
|
|
435
|
+
decision="blocked",
|
|
436
|
+
retry_recommendation="human-review",
|
|
437
|
+
target=target_label,
|
|
438
|
+
call_role="phase.ready-check",
|
|
439
|
+
phase_reached="phase.ready-check",
|
|
440
|
+
failure_kind="phase-ready-unverifiable",
|
|
441
|
+
summary=summary,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _deferred_record(reason: str, repo_root: Path, target_label: str) -> DecisionRecord:
|
|
446
|
+
return DecisionRecord(
|
|
447
|
+
decision="retry",
|
|
448
|
+
retry_recommendation="same-target",
|
|
449
|
+
target=target_label,
|
|
450
|
+
call_role="phase.ready-check",
|
|
451
|
+
phase_reached="phase.ready-check",
|
|
452
|
+
failure_kind="phase-ready-no",
|
|
453
|
+
summary=sanitize_text(reason, repo_root) or "Migration phase not ready",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _effort_deferred_record(
|
|
458
|
+
reason: str, repo_root: Path, target_label: str,
|
|
459
|
+
) -> DecisionRecord:
|
|
460
|
+
return DecisionRecord(
|
|
461
|
+
decision="retry",
|
|
462
|
+
retry_recommendation="same-target",
|
|
463
|
+
target=target_label,
|
|
464
|
+
call_role="phase.effort-budget",
|
|
465
|
+
phase_reached="phase.effort-budget",
|
|
466
|
+
failure_kind="phase-effort-over-budget",
|
|
467
|
+
summary=sanitize_text(reason, repo_root) or "Migration phase over effort budget",
|
|
468
|
+
)
|