This commit is contained in:
이충영 에이닷서비스개발
2026-03-15 17:54:30 +09:00
parent 28efd5bb8f
commit 0bbe0f6f7b
14 changed files with 871 additions and 183 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import time
from hashlib import sha256
@@ -34,6 +35,19 @@ from cross_eval.runtime_env import (
logger = logging.getLogger(__name__)
def _get_current_head(cwd: Path) -> str | None:
"""Return the current HEAD SHA for an existing repository."""
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=cwd,
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
def run_pipeline(
config: PipelineConfig,
cwd: Path | None = None,
@@ -124,8 +138,6 @@ def _copy_inputs_to_worktree(
Updates ``config.inputs`` in-place so subsequent reference refreshes use
worktree-local paths.
"""
import shutil
base_root = base_cwd.resolve()
track_external_inputs = config.preset_name == "plan-review"
inputs_dir = worktree_path / ".cross-eval-inputs"
@@ -134,7 +146,7 @@ def _copy_inputs_to_worktree(
# Exclude read-only input copies from git so they don't pollute code diffs.
(inputs_dir / ".gitignore").write_text("*\n", encoding="utf-8")
for key, val in list(config.inputs.items()):
if key.endswith("_ref") or not isinstance(val, Path):
if not isinstance(val, Path):
continue
if not val.exists():
continue
@@ -143,17 +155,71 @@ def _copy_inputs_to_worktree(
rel_path = resolved.relative_to(base_root)
except ValueError:
dest = inputs_dir / val.name
shutil.copy2(resolved, dest)
_copy_path(resolved, dest)
config.inputs[key] = dest
continue
worktree_target = worktree_path / rel_path
if not worktree_target.exists():
worktree_target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(resolved, worktree_target)
_copy_path(resolved, worktree_target)
config.inputs[key] = worktree_target
def _snapshot_input_paths(config: PipelineConfig) -> dict[str, Path]:
"""Capture original on-disk input paths before remapping into a worktree."""
return {
key: val
for key, val in config.inputs.items()
if isinstance(val, Path)
}
def _apply_worktree_inputs_to_base(
config: PipelineConfig,
original_inputs: dict[str, Path],
*,
cwd: Path,
) -> list[Path]:
"""Copy the final worktree-edited inputs back onto the user-provided paths."""
restored: list[Path] = []
for key, original_path in original_inputs.items():
current_path = config.inputs.get(key)
if not isinstance(current_path, Path) or not current_path.exists():
continue
if current_path.resolve() == original_path.resolve():
continue
_copy_path(current_path, original_path)
restored.append(original_path)
return restored
def _commit_base_repo_paths(cwd: Path, paths: list[Path], message: str) -> bool:
"""Commit changed input paths in the base repository when they live under cwd."""
rel_paths: list[str] = []
for path in paths:
try:
rel_paths.append(str(path.resolve().relative_to(cwd.resolve())))
except ValueError:
continue
if not rel_paths:
return False
subprocess.run(
["git", "add", "--", *rel_paths],
cwd=cwd,
capture_output=True,
check=True,
)
result = subprocess.run(
["git", "commit", "-m", message],
cwd=cwd,
capture_output=True,
text=True,
)
return result.returncode == 0
def _snapshot_repo_state(cwd: Path) -> dict[str, str]:
"""Capture the base repository working-tree state.
@@ -344,18 +410,26 @@ def _run_simple_pipeline(
# Setup shared worktree for agentic mode
worktree_path: Path | None = None
agent_execution_path: Path | None = None
agentic_branch_name: str | None = None
agentic_base_commit: str | None = None
original_input_paths: dict[str, Path] = {}
base_repo_state: dict[str, str] | None = None
base_repo_status: str | None = None
if not dry_run and _has_agentic_steps(config, config.pipeline):
worktree_path, agentic_branch_name, agentic_base_commit = _setup_worktree(
cwd, run_dir, config.preset_name,
)
_copy_inputs_to_worktree(config, worktree_path, base_cwd=cwd)
_refresh_input_references(config, input_contents)
base_repo_state = _snapshot_repo_state(cwd)
base_repo_status = _snapshot_repo_status(cwd)
if config.use_worktree:
worktree_path, agentic_branch_name, agentic_base_commit = _setup_worktree(
cwd, run_dir, config.preset_name,
)
original_input_paths = _snapshot_input_paths(config)
_copy_inputs_to_worktree(config, worktree_path, base_cwd=cwd)
_refresh_input_references(config, input_contents)
base_repo_state = _snapshot_repo_state(cwd)
base_repo_status = _snapshot_repo_status(cwd)
agent_execution_path = worktree_path
else:
agent_execution_path = cwd
agentic_base_commit = _get_current_head(cwd)
feedback = "(no feedback — first iteration)"
iterations: list[IterationResult] = []
@@ -381,7 +455,7 @@ def _run_simple_pipeline(
config.pipeline, config, input_contents, feedback,
i, config.max_iterations, cwd, timeout, dry_run,
run_dir=run_dir, output_iter=i,
worktree_path=worktree_path,
worktree_path=agent_execution_path,
runtime_env=runtime_env,
base_repo_state=base_repo_state,
base_repo_status=base_repo_status,
@@ -389,7 +463,7 @@ def _run_simple_pipeline(
)
# Intermediate commit so next iteration's diff only shows new changes
if worktree_path is not None:
if config.use_worktree and worktree_path is not None:
agentic_base_commit = _commit_iteration(worktree_path, config.preset_name, i, verdict)
iter_result = IterationResult(
@@ -480,8 +554,25 @@ def _run_simple_pipeline(
break
finally:
if config.use_worktree and worktree_path is not None and original_input_paths:
restored_paths = _apply_worktree_inputs_to_base(
config, original_input_paths, cwd=cwd,
)
if restored_paths:
try:
committed = _commit_base_repo_paths(
cwd,
restored_paths,
f"cross-eval: {config.preset_name} ({final_verdict})",
)
if committed:
logger.info(" Applied and committed final input changes in base repo.")
else:
logger.info(" Applied final input changes in base repo (no commit created).")
except Exception:
logger.warning(" Failed to commit final input changes in base repo", exc_info=True)
agentic_branch: str | None = None
if worktree_path is not None and agentic_branch_name is not None:
if config.use_worktree and worktree_path is not None and agentic_branch_name is not None:
agentic_branch = _finalize_worktree(
cwd, worktree_path, agentic_branch_name,
config.preset_name, final_verdict,
@@ -523,18 +614,26 @@ def _run_phased_pipeline(
# Setup shared worktree for agentic mode
all_phase_steps = [s for p in config.phases for s in p.steps]
worktree_path: Path | None = None
agent_execution_path: Path | None = None
agentic_branch_name: str | None = None
agentic_base_commit: str | None = None
original_input_paths: dict[str, Path] = {}
base_repo_state: dict[str, str] | None = None
base_repo_status: str | None = None
if not dry_run and _has_agentic_steps(config, all_phase_steps):
worktree_path, agentic_branch_name, agentic_base_commit = _setup_worktree(
cwd, run_dir, config.preset_name,
)
_copy_inputs_to_worktree(config, worktree_path, base_cwd=cwd)
_refresh_input_references(config, input_contents)
base_repo_state = _snapshot_repo_state(cwd)
base_repo_status = _snapshot_repo_status(cwd)
if config.use_worktree:
worktree_path, agentic_branch_name, agentic_base_commit = _setup_worktree(
cwd, run_dir, config.preset_name,
)
original_input_paths = _snapshot_input_paths(config)
_copy_inputs_to_worktree(config, worktree_path, base_cwd=cwd)
_refresh_input_references(config, input_contents)
base_repo_state = _snapshot_repo_state(cwd)
base_repo_status = _snapshot_repo_status(cwd)
agent_execution_path = worktree_path
else:
agent_execution_path = cwd
agentic_base_commit = _get_current_head(cwd)
iterations: list[IterationResult] = []
feedback = "(no feedback — first iteration)"
@@ -581,7 +680,7 @@ def _run_phased_pipeline(
phase.steps, config, input_contents, feedback,
pi, phase.max_iterations, cwd, timeout, dry_run,
run_dir=run_dir, output_iter=global_iter, phase_name=phase.name,
worktree_path=worktree_path,
worktree_path=agent_execution_path,
runtime_env=runtime_env,
base_repo_state=base_repo_state,
base_repo_status=base_repo_status,
@@ -589,7 +688,7 @@ def _run_phased_pipeline(
)
# Intermediate commit so next iteration's diff only shows new changes
if worktree_path is not None:
if config.use_worktree and worktree_path is not None:
agentic_base_commit = _commit_iteration(
worktree_path, f"{config.preset_name}/{phase.name}",
global_iter, verdict,
@@ -717,8 +816,25 @@ def _run_phased_pipeline(
final_verdict = "PASS" if phase_converged else "MAX_ITERATIONS_REACHED"
finally:
if config.use_worktree and worktree_path is not None and original_input_paths:
restored_paths = _apply_worktree_inputs_to_base(
config, original_input_paths, cwd=cwd,
)
if restored_paths:
try:
committed = _commit_base_repo_paths(
cwd,
restored_paths,
f"cross-eval: {config.preset_name} ({final_verdict})",
)
if committed:
logger.info(" Applied and committed final input changes in base repo.")
else:
logger.info(" Applied final input changes in base repo (no commit created).")
except Exception:
logger.warning(" Failed to commit final input changes in base repo", exc_info=True)
agentic_branch: str | None = None
if worktree_path is not None and agentic_branch_name is not None:
if config.use_worktree and worktree_path is not None and agentic_branch_name is not None:
agentic_branch = _finalize_worktree(
cwd, worktree_path, agentic_branch_name,
config.preset_name, final_verdict,
@@ -752,6 +868,8 @@ def _load_inputs(config: PipelineConfig) -> dict[str, str]:
for key, val in config.inputs.items():
if key.endswith("_ref"):
input_contents[key] = str(val)
elif key == "docs":
input_contents[key] = _load_docs_input(config, current_value=val)
elif isinstance(val, str):
input_contents[key] = val
else:
@@ -767,6 +885,8 @@ def _refresh_inputs(
for key, val in config.inputs.items():
if key.endswith("_ref"):
input_contents[key] = str(val)
elif key == "docs":
input_contents[key] = _load_docs_input(config, current_value=val)
elif isinstance(val, str):
input_contents[key] = val
elif isinstance(val, Path) and val.exists():
@@ -774,6 +894,40 @@ def _refresh_inputs(
_refresh_input_references(config, input_contents)
def _load_docs_input(config: PipelineConfig, *, current_value: Path | str) -> str:
"""Load docs content from docs_ref when available so edits are visible next iteration."""
docs_ref = config.inputs.get("docs_ref")
docs_path = docs_ref if isinstance(docs_ref, Path) else None
if docs_path is not None and docs_path.exists():
if docs_path.is_dir():
return _read_docs_tree(docs_path)
try:
return docs_path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return ""
if isinstance(current_value, str):
return current_value
if current_value.exists() and current_value.is_file():
return current_value.read_text(encoding="utf-8")
return ""
def _read_docs_tree(docs_dir: Path) -> str:
"""Read all visible text files under a docs tree and concatenate them."""
parts: list[str] = []
for f in sorted(
path for path in docs_dir.rglob("*")
if path.is_file() and not any(part.startswith(".") for part in path.relative_to(docs_dir).parts)
):
try:
content = f.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
rel_path = f.relative_to(docs_dir).as_posix()
parts.append(f"### {rel_path}\n{content}")
return "\n\n".join(parts)
def _refresh_input_references(
config: PipelineConfig,
input_contents: dict[str, str],
@@ -1703,3 +1857,12 @@ def _save_report(run_dir: Path, config: PipelineConfig, result: PipelineResult)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report, encoding="utf-8")
logger.info("Report saved: %s", report_path)
def _copy_path(src: Path, dest: Path) -> None:
"""Copy a file or directory into the worktree, preserving structure."""
if src.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True)
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)