Files
Agentic-AI-Template/Codex/Coding/harness framework cs/scripts/execute.py
T
2026-09-08 10:30:07 +09:00

593 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push | --dry-run]
"""
import argparse
import contextlib
import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class ContextError(ValueError):
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
frames = "◐◓◑◒"
stop = threading.Event()
t0 = time.monotonic()
def _animate():
idx = 0
while not stop.wait(0.12):
sec = int(time.monotonic() - t0)
sys.stderr.write(f"\r{frames[idx % len(frames)]} {label} [{sec}s]")
sys.stderr.flush()
idx += 1
sys.stderr.write("\r" + " " * (len(label) + 20) + "\r")
sys.stderr.flush()
th = threading.Thread(target=_animate, daemon=True)
th.start()
info = types.SimpleNamespace(elapsed=0.0)
try:
yield info
finally:
stop.set()
th.join()
info.elapsed = time.monotonic() - t0
class StepExecutor:
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
MAX_RETRIES = 3
FEAT_MSG = "feat({phase}): step {num} — {name}"
CHORE_MSG = "chore({phase}): step {num} output"
TZ = timezone(timedelta(hours=9))
def __init__(self, phase_dir_name: str, *, auto_push: bool = False):
if (not phase_dir_name or phase_dir_name in {".", ".."}
or any(c in phase_dir_name for c in "/\\:")):
raise ContextError("phase must be a directory name under phases/")
self._root = str(ROOT)
self._phases_dir = ROOT / "phases"
self._phase_dir = self._phases_dir / phase_dir_name
if self._phase_dir.resolve().parent != self._phases_dir.resolve():
raise ContextError("phase resolves outside phases/")
self._phase_dir_name = phase_dir_name
self._top_index_file = self._phases_dir / "index.json"
self._auto_push = auto_push
if not self._phase_dir.is_dir():
print(f"ERROR: {self._phase_dir} not found")
sys.exit(1)
self._index_file = self._phase_dir / "index.json"
if not self._index_file.exists():
print(f"ERROR: {self._index_file} not found")
sys.exit(1)
idx = self._read_json(self._index_file)
self._project = idx.get("project", "project")
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
def run(self, *, dry_run: bool = False):
if dry_run and self._auto_push:
raise ContextError("--dry-run and --push cannot be combined")
self._print_header()
self._check_blockers()
pending = [s for s in self._read_json(self._index_file)["steps"]
if s["status"] == "pending"]
if dry_run:
if not pending:
print("No pending steps.")
return
sections = self._prompt_sections(pending[0])
for label, body in sections:
print(f" {label}: {len(body)} characters")
print(f" Total prompt: {len(self._render_prompt(sections))} characters")
return
for step in pending:
self._build_prompt(step)
self._checkout_branch()
self._ensure_created_at()
self._execute_all_steps()
self._finalize()
# --- timestamps ---
def _stamp(self) -> str:
return datetime.now(self.TZ).strftime("%Y-%m-%dT%H:%M:%S%z")
# --- JSON I/O ---
@staticmethod
def _read_json(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
@staticmethod
def _write_json(p: Path, data: dict):
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# --- git ---
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
return subprocess.run(cmd, cwd=self._root, capture_output=True,
text=True, encoding="utf-8", errors="replace")
def _checkout_branch(self):
branch = f"feat-{self._phase_name}"
r = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
if r.returncode != 0:
print(f" ERROR: git을 사용할 수 없거나 git repo가 아닙니다.")
print(f" {r.stderr.strip()}")
sys.exit(1)
if r.stdout.strip() == branch:
return
r = self._run_git("rev-parse", "--verify", branch)
r = self._run_git("checkout", branch) if r.returncode == 0 else self._run_git("checkout", "-b", branch)
if r.returncode != 0:
print(f" ERROR: 브랜치 '{branch}' checkout 실패.")
print(f" {r.stderr.strip()}")
print(f" Hint: 변경사항을 stash하거나 commit한 후 다시 시도하세요.")
sys.exit(1)
print(f" Branch: {branch}")
def _commit_step(self, step_num: int, step_name: str):
output_rel = f"phases/{self._phase_dir_name}/step{step_num}-output.json"
index_rel = f"phases/{self._phase_dir_name}/index.json"
self._run_git("add", "-A")
self._run_git("reset", "HEAD", "--", output_rel)
self._run_git("reset", "HEAD", "--", index_rel)
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.FEAT_MSG.format(phase=self._phase_name, num=step_num, name=step_name)
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" Commit: {msg}")
else:
print(f" WARN: 코드 커밋 실패: {r.stderr.strip()}")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.CHORE_MSG.format(phase=self._phase_name, num=step_num)
r = self._run_git("commit", "-m", msg)
if r.returncode != 0:
print(f" WARN: housekeeping 커밋 실패: {r.stderr.strip()}")
# --- top-level index ---
def _update_top_index(self, status: str):
if not self._top_index_file.exists():
return
top = self._read_json(self._top_index_file)
ts = self._stamp()
for phase in top.get("phases", []):
if phase.get("dir") == self._phase_dir_name:
phase["status"] = status
ts_key = {"completed": "completed_at", "error": "failed_at", "blocked": "blocked_at"}.get(status)
if ts_key:
phase[ts_key] = ts
break
self._write_json(self._top_index_file, top)
# --- guardrails & context ---
def _document_path(self, raw: str) -> Path:
if (not isinstance(raw, str) or not raw.strip()
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
or any(c in raw for c in "*?[]:")):
raise ContextError(f"context file must be a repository-relative file: {raw!r}")
root = Path(self._root).resolve()
path = (root / raw.replace("\\", "/")).resolve()
if not path.is_relative_to(root):
raise ContextError(f"context file resolves outside repository: {raw}")
return path
def _read_document(self, path: Path) -> tuple[str, str]:
label = path.relative_to(Path(self._root).resolve()).as_posix()
try:
return label, path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ContextError(f"cannot read UTF-8 document {label}: {exc}") from exc
def _prompt_sections(self, step: dict, prev_error: Optional[str] = None) -> list:
self._validation_mode(step)
paths = step.get("context_files", [])
if not isinstance(paths, list):
raise ContextError("context_files must be a string array")
step_path = self._document_path(f"phases/{self._phase_dir_name}/step{step['step']}.md")
agents = self._document_path("AGENTS.md")
handoff = self._document_path(f"docs/phase/{self._phase_dir_name}/handoff.md")
seen = {step_path, agents, handoff}
sections = [self._read_document(agents)] if agents.exists() else []
sections.append(("작업 규칙", self._work_rules(step)))
for raw in paths:
path = self._document_path(raw)
if not path.is_file():
raise ContextError(f"context file not found: {raw}")
if path not in seen:
sections.append(self._read_document(path))
seen.add(path)
if handoff.exists():
sections.append(self._read_document(handoff))
else:
summary = self._build_step_context(self._read_json(self._index_file), step["step"])
if summary:
sections.append((f"이전 step 요약 (phases/{self._phase_dir_name}/index.json)", summary))
if prev_error:
sections.append(("이전 시도 실패", self._bounded_error(prev_error)))
sections.append(("직전 원본 로그",
f"phases/{self._phase_dir_name}/step{step['step']}-output.json"))
sections.append(self._read_document(step_path))
return sections
@staticmethod
def _render_prompt(sections: list) -> str:
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
def _build_prompt(self, step: dict, prev_error: Optional[str] = None) -> str:
return self._render_prompt(self._prompt_sections(step, prev_error))
@staticmethod
def _validation_mode(step: dict) -> str:
mode = step.get("validation", "full")
if mode not in ("full", "docs-only"):
raise ContextError("validation must be full or docs-only")
return mode
@staticmethod
def _bounded_error(error: str) -> str:
if len(error) <= 4000:
return error
marker = "\n[중간 생략; 원본 로그 참조]\n"
return error[:1500] + marker + error[-(2500 - len(marker)):]
@staticmethod
def _build_step_context(index: dict, step_num: int) -> str:
prior = [s for s in index["steps"]
if s["step"] < step_num and s["status"] == "completed"]
if not prior:
return ""
step = max(prior, key=lambda s: s["step"])
summary = step.get("summary", "")
if not summary:
return ""
if len(summary) > 1000:
summary = summary[:980] + "\n[이후 생략; index 참조]"
return f"Step {step['step']} ({step['name']}): {summary}"
def _work_rules(self, step: dict) -> str:
notes = f"docs/phase/{self._phase_dir_name}"
return (
f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n"
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. 성공·실패·차단을 보고하기 전에 {notes}/handoff.md와 {notes}/step{step['step']}.md를 갱신하라.\n"
f" handoff는 현재 상황, 유효한 결정과 이유, 미해결 문제, 검증 명령·결과, 다음 시작점과 상세 기록 링크를 약 60줄로 유지하라.\n"
f" step 기록에는 변경 파일, 결정, 검증, 실패한 접근과 이유를 적고 원본 로그를 복사하지 마라.\n"
f"6. phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, prompt: str) -> dict:
step_num, step_name = step["step"], step["name"]
child_env = os.environ.copy()
child_env.pop("HARNESS_DOCS_ONLY_PHASE", None)
if self._validation_mode(step) == "docs-only":
child_env["HARNESS_DOCS_ONLY_PHASE"] = f"phases/{self._phase_dir_name}"
command = [
"codex",
"exec",
"--json",
"--sandbox",
"workspace-write",
"--dangerously-bypass-hook-trust",
"--cd",
self._root,
"-",
]
try:
result = subprocess.run(
command,
cwd=self._root,
input=prompt,
env=child_env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=1800,
)
except FileNotFoundError as exc:
raise CodexEnvironmentError(
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
) from exc
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout or ""
stderr = exc.stderr or ""
if isinstance(stdout, bytes):
stdout = stdout.decode("utf-8", errors="replace")
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="replace")
result = subprocess.CompletedProcess(
command, 124, stdout, f"Codex timed out after 1800 seconds\n{stderr}",
)
if result.returncode != 0:
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
if result.stderr:
print(f" stderr: {result.stderr[:500]}")
output = {
"step": step_num, "name": step_name,
"exitCode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr,
}
out_path = self._phase_dir / f"step{step_num}-output.json"
self._write_json(out_path, output)
return output
@staticmethod
def _codex_environment_failure(output: dict) -> Optional[str]:
if output.get("exitCode", 0) == 0:
return None
diagnostic = (
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
).lower()
markers = {
"not logged in": "Codex 인증이 필요합니다.",
"authentication": "Codex 인증에 실패했습니다.",
"unexpected argument": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
"unrecognized option": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
}
for marker, message in markers.items():
if marker in diagnostic:
return message
return None
# --- 헤더 & 검증 ---
def _print_header(self):
print(f"\n{'='*60}")
print(f" Harness Step Executor")
print(f" Phase: {self._phase_name} | Steps: {self._total}")
if self._auto_push:
print(f" Auto-push: enabled")
print(f"{'='*60}")
def _check_blockers(self):
index = self._read_json(self._index_file)
for s in reversed(index["steps"]):
if s["status"] == "error":
print(f"\n ✗ Step {s['step']} ({s['name']}) failed.")
print(f" Error: {s.get('error_message', 'unknown')}")
print(f" Fix and reset status to 'pending' to retry.")
sys.exit(1)
if s["status"] == "blocked":
print(f"\n ⏸ Step {s['step']} ({s['name']}) blocked.")
print(f" Reason: {s.get('blocked_reason', 'unknown')}")
print(f" Resolve and reset status to 'pending' to retry.")
sys.exit(2)
if s["status"] != "pending":
break
def _ensure_created_at(self):
index = self._read_json(self._index_file)
if "created_at" not in index:
index["created_at"] = self._stamp()
self._write_json(self._index_file, index)
# --- 실행 루프 ---
def _validate_completed_step(self, step: dict) -> tuple[bool, str]:
# Stop can halt a Codex turn with exit 0 even when its build failed.
# Independently verify before accepting the agent's completed state.
from hooks.stop_validation import _documentation_only_changes, run_validations
root = Path(self._root)
deadline = time.monotonic() + 1800
if self._validation_mode(step) == "docs-only" and _documentation_only_changes(
root, f"phases/{self._phase_dir_name}", deadline=deadline,
):
return True, ""
return run_validations(root, deadline=deadline)
def _execute_single_step(self, step: dict) -> bool:
"""단일 step 실행 (재시도 포함). 완료되면 True, 실패/차단이면 False."""
step_num, step_name = step["step"], step["name"]
done = sum(1 for s in self._read_json(self._index_file)["steps"] if s["status"] == "completed")
prev_error = None
for attempt in range(1, self.MAX_RETRIES + 1):
index = self._read_json(self._index_file)
step = next(s for s in index["steps"] if s["step"] == step_num)
prompt = self._build_prompt(step, prev_error)
tag = f"Step {step_num}/{self._total - 1} ({done} done): {step_name}"
if attempt > 1:
tag += f" [retry {attempt}/{self.MAX_RETRIES}]"
with progress_indicator(tag) as pi:
output = self._invoke_codex(step, prompt)
elapsed = int(pi.elapsed)
index = self._read_json(self._index_file)
if output["exitCode"] != 0:
for s in index["steps"]:
if s["step"] == step_num and s["status"] == "completed":
s["status"] = "error"
s.pop("completed_at", None)
s.pop("summary", None)
self._write_json(self._index_file, index)
environment_failure = self._codex_environment_failure(output)
if environment_failure:
raise CodexEnvironmentError(environment_failure)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
if status == "completed" and output["exitCode"] == 0:
with progress_indicator("Verifying completed step"):
valid, validation_error = self._validate_completed_step(step)
if not valid:
status = "error"
current = next(s for s in index["steps"] if s["step"] == step_num)
current.update(status="error", error_message=validation_error)
current.pop("completed_at", None)
current.pop("summary", None)
self._write_json(self._index_file, index)
ts = self._stamp()
if status == "completed" and output["exitCode"] == 0:
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
return True
if status == "blocked":
for s in index["steps"]:
if s["step"] == step_num:
s["blocked_at"] = ts
self._write_json(self._index_file, index)
reason = next((s.get("blocked_reason", "") for s in index["steps"] if s["step"] == step_num), "")
print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]")
print(f" Reason: {reason}")
self._update_top_index("blocked")
sys.exit(2)
current = next(s for s in index["steps"] if s["step"] == step_num)
reason = current.get("error_message") or (
f"Codex exited with code {output['exitCode']}"
if output["exitCode"] else "Step did not update status"
)
diagnostics = [reason]
for stream in ("stderr", "stdout"):
if output.get(stream, "").strip():
diagnostics.append(f"{stream}:\n{output[stream].strip()}")
err_msg = self._bounded_error("\n\n".join(diagnostics))
if attempt < self.MAX_RETRIES:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "pending"
s.pop("error_message", None)
self._write_json(self._index_file, index)
prev_error = err_msg
print(f" ↻ Step {step_num}: retry {attempt}/{self.MAX_RETRIES} — {err_msg}")
else:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "error"
s["error_message"] = f"[{self.MAX_RETRIES}회 시도 후 실패] {err_msg}"
s["failed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✗ Step {step_num}: {step_name} failed after {self.MAX_RETRIES} attempts [{elapsed}s]")
print(f" Error: {err_msg}")
self._update_top_index("error")
sys.exit(1)
return False # unreachable
def _execute_all_steps(self):
while True:
index = self._read_json(self._index_file)
pending = next((s for s in index["steps"] if s["status"] == "pending"), None)
if pending is None:
print("\n All steps completed!")
return
step_num = pending["step"]
for s in index["steps"]:
if s["step"] == step_num and "started_at" not in s:
s["started_at"] = self._stamp()
self._write_json(self._index_file, index)
break
self._execute_single_step(pending)
def _finalize(self):
index = self._read_json(self._index_file)
index["completed_at"] = self._stamp()
self._write_json(self._index_file, index)
self._update_top_index("completed")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = f"chore({self._phase_name}): mark phase completed"
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" ✓ {msg}")
if self._auto_push:
branch = f"feat-{self._phase_name}"
r = self._run_git("push", "-u", "origin", branch)
if r.returncode != 0:
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
sys.exit(1)
print(f" ✓ Pushed to origin/{branch}")
print(f"\n{'='*60}")
print(f" Phase '{self._phase_name}' completed!")
print(f"{'='*60}")
def main():
parser = argparse.ArgumentParser(description="Harness Step Executor")
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--push", action="store_true", help="Push branch after completion")
mode.add_argument("--dry-run", action="store_true", help="Preview first pending step context without execution")
args = parser.parse_args()
try:
StepExecutor(args.phase_dir, auto_push=args.push).run(dry_run=args.dry_run)
except (CodexEnvironmentError, ContextError) as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()