Files
qt6-migration-pipeline/app/migration_pipeline/migrator.py
T

409 lines
16 KiB
Python

"""Per-file async migration agent.
Lifecycle for each file x track:
1. Read source from worktree.
2. Split into semantic chunks when file > CHUNK_SIZE_LINES.
3. Retry loop (up to MAX_RETRIES):
a. Migrate (chunk-aware on iteration 0; whole-file on retries).
- Whole-file path uses a multi-turn conversation: each retry appends
the error feedback and the model's previous reply to the same
message list, mirroring the Copilot CLI behaviour.
- Chunk path: each chunk migrated in parallel with its own independent
mini message list (Decision 3 - parallel execution preserved).
b. Consistency pass on iteration 0 (chunks only): uses its own fresh
multi-turn conversation so it is structurally prepared for future
per-pass retries (Decision 1 - scope B).
c. Write result to worktree.
d. Fast per-file syntax check.
e. On success: generate tests, full build + CTest, evaluate gates.
f. On all gates passed: commit, return SUCCESS.
g. On failure: collect error context, continue loop.
4. On budget exceeded or retries exhausted: quarantine + restore original.
Conversation history is strictly scoped per file (Decision 4 - Option A):
all message lists are local variables inside migrate_file and are discarded
when the function returns.
"""
from __future__ import annotations
import asyncio
import logging
import time
from pathlib import Path
import httpx
from migration_pipeline.api_client import call_api_multiturn, select_model
from migration_pipeline.build_runner import (
cmake_configure,
parse_warnings,
syntax_check_file,
)
from migration_pipeline.chunker import CodeChunk, split_semantic
from migration_pipeline.config import (
CHUNK_SIZE_LINES,
HARD_BUDGET_LARGE_FILE,
HARD_BUDGET_SMALL_FILE,
LARGE_FILE_THRESHOLD,
MAX_RETRIES,
SOFT_BUDGET_PER_FILE,
)
from migration_pipeline.git_manager import (
commit_generated_tests,
commit_migrated_file,
)
from migration_pipeline.models import (
FileResult,
FileStatus,
QualityGateResult,
Track,
)
from migration_pipeline.quality_gate import evaluate_gates
from migration_pipeline.quarantine import quarantine_file
from migration_pipeline.test_generator import generate_tests
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# System prompts
# ---------------------------------------------------------------------------
_SYSTEM_A_WHOLE = (
"Du bist ein Qt5\u2192Qt6 Migrations-Agent (Track A: konservative Kompatibilit\u00e4t). "
"Migriere ausschlie\u00dflich die notwendigen Qt5-API-Aufrufe auf ihre Qt6-\u00c4quivalente. "
"Behalte den vorhandenen Code-Stil und alle Kommentare unver\u00e4ndert. "
"Keine Modernisierungen, keine Umstrukturierungen. "
"Gib NUR den modifizierten C++ Quellcode zur\u00fcck \u2013 keine Erkl\u00e4rungen, kein Markdown."
)
_SYSTEM_B_WHOLE = (
"Du bist ein Qt5\u2192Qt6 Migrations-Agent (Track B: Modernisierung). "
"Migriere auf Qt6 UND modernisiere gleichzeitig auf C++20: "
"nutze auto, Ranges, Concepts, std::optional, structured bindings, "
"constexpr/consteval, [[nodiscard]], if constexpr, lambdas. "
"Ersetze veraltete Qt5-APIs vollst\u00e4ndig durch ihre Qt6-Pendants. "
"Gib NUR den modifizierten C++ Quellcode zur\u00fcck \u2013 keine Erkl\u00e4rungen, kein Markdown."
)
_SYSTEM_A_CHUNK = (
"Du bist ein Qt5\u2192Qt6 Migrations-Agent (Track A) f\u00fcr einen CODE-CHUNK. "
"Der Chunk ist Teil einer gr\u00f6\u00dferen Datei \u2013 erhalte alle Includes und Grenzen. "
"Migriere NUR Qt5\u2192Qt6 API-\u00c4nderungen innerhalb dieses Chunks. "
"Gib NUR C++ Quellcode zur\u00fcck."
)
_SYSTEM_B_CHUNK = (
"Du bist ein Qt5\u2192Qt6 Modernisierungs-Agent (Track B) f\u00fcr einen CODE-CHUNK. "
"Migriere auf Qt6 + C++20 innerhalb dieses Chunks. "
"Behalte alle Chunk-Grenzen, Namespaces und Includes exakt bei. "
"Gib NUR C++ Quellcode zur\u00fcck."
)
_SYSTEM_MERGE_CHECK = (
"Du pr\u00fcfst einen zusammengesetzten C++ Quellcode, der aus mehreren migrierten Chunks "
"zusammengef\u00fcgt wurde. Stelle sicher: "
"1) Include-Guards / #pragma once nur einmal vorhanden. "
"2) Alle Namespaces korrekt geschlossen. "
"3) Keine doppelten Deklarationen oder Definitionen. "
"4) Keine widerspr\u00fcchlichen Makros oder typedefs. "
"Korrigiere ausschlie\u00dflich echte Merge-Fehler \u2013 keine sonstigen \u00c4nderungen. "
"Gib NUR den korrigierten C++ Quellcode zur\u00fcck."
)
def _whole_system(track: Track) -> str:
return _SYSTEM_A_WHOLE if track == Track.A else _SYSTEM_B_WHOLE
def _chunk_system(track: Track) -> str:
return _SYSTEM_A_CHUNK if track == Track.A else _SYSTEM_B_CHUNK
# ---------------------------------------------------------------------------
# Multi-turn API helpers
# ---------------------------------------------------------------------------
async def _migrate_whole_mt(
client: httpx.AsyncClient,
model: str,
code: str,
track: Track,
messages: list[dict],
error_context: str = "",
) -> str:
"""Whole-file migration via a growing multi-turn conversation.
*messages* is mutated in place:
- On the first call (empty list) the system prompt is prepended and the
initial migration request is sent.
- On subsequent calls the error feedback is appended as a new user turn
after the model's previous reply, so the model can learn from its
own mistakes within the same conversation.
Returns the migrated code (= the latest assistant reply).
"""
if not messages:
# First call: initialise the conversation.
messages.append({"role": "system", "content": _whole_system(track)})
user_msg = f"Portiere folgenden Qt5-Code nach Qt6:\n\n{code}"
else:
# Retry: the model sees its own previous reply + the compiler error.
user_msg = (
f"Fehler im vorherigen Versuch:\n{error_context}\n\n"
f"Bitte korrigiere den folgenden Code:\n\n{code}"
)
messages.append({"role": "user", "content": user_msg})
reply = await call_api_multiturn(client, model, messages)
messages.append({"role": "assistant", "content": reply})
return reply
async def _migrate_chunk_mt(
client: httpx.AsyncClient,
model: str,
chunk: CodeChunk,
track: Track,
) -> str:
"""Migrate one chunk with its own independent mini messages list.
Each chunk is structurally multi-turn capable (Decision 3 - Option B):
the messages list is fresh per chunk, ensuring full parallel independence
while being ready for per-chunk retries in future extensions.
"""
messages: list[dict] = [
{"role": "system", "content": _chunk_system(track)},
{
"role": "user",
"content": (
f"Migriere Chunk #{chunk.index} "
f"(Zeilen {chunk.start_line}\u2013{chunk.end_line - 1}):\n\n{chunk.content}"
),
},
]
reply = await call_api_multiturn(client, model, messages)
messages.append({"role": "assistant", "content": reply})
return reply
async def _consistency_pass_mt(
client: httpx.AsyncClient,
model: str,
merged: str,
) -> str:
"""Consistency pass with its own fresh multi-turn conversation.
Uses a dedicated message list (Decision 1 - Option B scope), independent
of the whole-file retry conversation, so it can be extended with its own
retry turns in future without cross-contaminating the migration history.
"""
messages: list[dict] = [
{"role": "system", "content": _SYSTEM_MERGE_CHECK},
{"role": "user", "content": f"Pr\u00fcfe und korrigiere:\n\n{merged}"},
]
reply = await call_api_multiturn(client, model, messages)
messages.append({"role": "assistant", "content": reply})
return reply
# ---------------------------------------------------------------------------
# Budget helpers
# ---------------------------------------------------------------------------
def _hard_budget(line_count: int) -> float:
return HARD_BUDGET_LARGE_FILE if line_count > LARGE_FILE_THRESHOLD else HARD_BUDGET_SMALL_FILE
# ---------------------------------------------------------------------------
# Core agent
# ---------------------------------------------------------------------------
async def migrate_file(
*,
client: httpx.AsyncClient,
source_file: Path, # absolute path inside the worktree
worktree_root: Path,
build_dir: Path,
track: Track,
batch_id: str,
) -> FileResult:
"""Migrate one file and return a FileResult."""
file_str = str(source_file)
original_code = source_file.read_text(errors="ignore")
line_count = len(original_code.splitlines())
model = select_model(line_count)
budget = _hard_budget(line_count)
is_large = line_count > CHUNK_SIZE_LINES
chunks = split_semantic(original_code, CHUNK_SIZE_LINES) if is_large else []
chunk_info = (
f"{len(chunks)} chunks, ~{CHUNK_SIZE_LINES} Zeilen/Chunk" if chunks
else f"vollst\u00e4ndig ({line_count} Zeilen)"
)
start = time.monotonic()
current_code = original_code
error_context = ""
last_gate = QualityGateResult()
iteration = 0
test_prefix: str | None = None
# Multi-turn conversation for whole-file migrations.
# Decision 4 (Option A): strictly per-file - this list is local and
# discarded when migrate_file returns.
whole_messages: list[dict] = []
while iteration < MAX_RETRIES:
elapsed = time.monotonic() - start
# --- Hard budget check --------------------------------------------
if elapsed >= budget:
logger.warning(
"[BUDGET] %s [%s] \u2013 %.0f s (budget %.0f s)",
source_file.name, track.value, elapsed, budget,
)
break
# --- Soft budget warning ------------------------------------------
if elapsed >= SOFT_BUDGET_PER_FILE and iteration == 0:
logger.warning(
"[SOFT BUDGET] %s [%s] exceeded soft limit at %.0f s",
source_file.name, track.value, elapsed,
)
try:
# ---- 1. Migrate ------------------------------------------------
if is_large and iteration == 0:
# Parallel chunk migration: each chunk gets its own fresh
# multi-turn messages list (Decision 3 - Option B).
migrated_parts = await asyncio.gather(
*[_migrate_chunk_mt(client, model, c, track) for c in chunks]
)
merged = "\n".join(migrated_parts)
# Consistency pass uses its own independent multi-turn
# conversation (Decision 1 - Option B).
migrated_code = await _consistency_pass_mt(client, model, merged)
else:
# Whole-file path: conversation grows per retry so the model
# sees its own previous answer alongside the compiler error
# (Decision 1 - Option B, Decision 4 - Option A).
migrated_code = await _migrate_whole_mt(
client, model, current_code, track, whole_messages, error_context
)
# ---- 2. Write migrated file to worktree -----------------------
source_file.write_text(migrated_code)
# ---- 3. Fast per-file syntax check ----------------------------
syntax_ok, syn_out, syn_err = await syntax_check_file(
source_file, build_dir
)
syntax_warnings = parse_warnings(syn_out + "\n" + syn_err)
if not syntax_ok:
logger.info(
"[SYNTAX FAIL] %s [%s] iter %d",
source_file.name, track.value, iteration,
)
error_context = f"Compiler-Syntaxfehler:\n{syn_err}"
current_code = migrated_code
iteration += 1
continue
# ---- 4. Generate tests ----------------------------------------
logger.info(
"[QA] Generating tests for %s [%s]...",
source_file.name, track.value,
)
test_prefix = await generate_tests(
client, model, file_str, migrated_code,
worktree_root, track.value,
)
# ---- 5. Reconfigure + full build (to include new tests) -------
await cmake_configure(worktree_root, build_dir)
last_gate = await evaluate_gates(build_dir, test_prefix)
# Merge syntax-check warnings into gate result.
last_gate.build_warnings = syntax_warnings + last_gate.build_warnings
if last_gate.all_gates_passed:
elapsed_final = time.monotonic() - start
logger.info(
"[SUCCESS] %s [%s] in %.0f s \u2013 %d warning(s)",
source_file.name, track.value, elapsed_final,
last_gate.total_warnings,
)
# Commit migrated file and generated tests.
commit_migrated_file(source_file, worktree_root, track.value)
tests_dir = worktree_root / "tests_generated"
commit_generated_tests(
tests_dir, worktree_root, track.value, test_prefix
)
return FileResult(
file_path=file_str,
track=track.value,
status=FileStatus.SUCCESS.value,
iterations=iteration + 1,
elapsed_seconds=round(elapsed_final, 1),
gate=last_gate,
migrated_path=file_str,
test_name=test_prefix or "",
)
# Gates failed - build error or test failure.
error_context = (
f"Build-Fehler:\n{last_gate.build_errors}\n\n"
f"Test-Ausgabe:\n{last_gate.test_output}"
)
logger.info(
"[GATE FAIL] %s [%s] iter %d \u2013 build_ok=%s, "
"existing_ok=%s, new_ok=%s",
source_file.name, track.value, iteration,
last_gate.build_ok, last_gate.existing_tests_ok,
last_gate.new_tests_ok,
)
current_code = migrated_code
iteration += 1
except Exception as exc: # noqa: BLE001
logger.error(
"[ERROR] %s [%s] iter %d: %s",
source_file.name, track.value, iteration, exc,
)
error_context = f"API/System-Fehler: {exc}"
# Restore original so next iteration starts clean.
source_file.write_text(original_code)
iteration += 1
# ---- Quarantine --------------------------------------------------------
elapsed_total = time.monotonic() - start
logger.warning(
"[QUARANTINE] %s [%s] after %d iter, %.0f s",
source_file.name, track.value, iteration, elapsed_total,
)
source_file.write_text(original_code) # restore
q_entry = quarantine_file(
file_path=file_str,
track=track.value,
build_errors=last_gate.build_errors,
test_output=last_gate.test_output,
last_code=current_code,
elapsed=elapsed_total,
budget=budget,
retry_count=iteration,
chunk_info=chunk_info,
)
return FileResult(
file_path=file_str,
track=track.value,
status=FileStatus.QUARANTINED.value,
iterations=iteration,
elapsed_seconds=round(elapsed_total, 1),
gate=last_gate,
quarantine_entry=q_entry,
)