modify harness framework

This commit is contained in:
김경종
2026-09-08 10:30:07 +09:00
parent 1239d5b9f6
commit 1a0f3c9aaf
68 changed files with 6729 additions and 957 deletions
@@ -3,7 +3,7 @@
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push]
python scripts/execute.py <phase-dir> [--push | --dry-run]
"""
import argparse
@@ -16,7 +16,7 @@ import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
@@ -26,6 +26,10 @@ class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class ContextError(ValueError):
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
@@ -63,9 +67,14 @@ class StepExecutor:
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
@@ -84,13 +93,27 @@ class StepExecutor:
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
def run(self):
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()
guardrails = self._load_guardrails()
self._ensure_created_at()
self._execute_all_steps(guardrails)
self._execute_all_steps()
self._finalize()
# --- timestamps ---
@@ -112,7 +135,8 @@ class StepExecutor:
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
return subprocess.run(cmd, cwd=self._root, capture_output=True, text=True)
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}"
@@ -178,69 +202,118 @@ class StepExecutor:
# --- guardrails & context ---
def _load_guardrails(self) -> str:
sections = []
agents_md = ROOT / "AGENTS.md"
if agents_md.exists():
sections.append(
"## 프로젝트 규칙 (AGENTS.md)\n\n"
f"{agents_md.read_text(encoding='utf-8')}"
)
docs_dir = ROOT / "docs"
if docs_dir.is_dir():
for doc in sorted(docs_dir.glob("*.md")):
sections.append(
f"## {doc.stem}\n\n{doc.read_text(encoding='utf-8')}"
)
return "\n\n---\n\n".join(sections) if sections else ""
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 _build_step_context(index: dict) -> str:
lines = [
f"- Step {s['step']} ({s['name']}): {s['summary']}"
for s in index["steps"]
if s["status"] == "completed" and s.get("summary")
]
if not lines:
return ""
return "## 이전 Step 산출물\n\n" + "\n".join(lines) + "\n\n"
def _render_prompt(sections: list) -> str:
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
def _build_preamble(self, guardrails: str, step_context: str,
prev_error: Optional[str] = None) -> str:
retry_section = ""
if prev_error:
retry_section = (
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
f"{prev_error}\n\n---\n\n"
)
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"{guardrails}\n\n---\n\n"
f"{step_context}{retry_section}"
f"## 작업 규칙\n\n"
f"1. 이전 step에서 작성된 코드를 확인하고 일관성을 유지하라.\n"
f"2. 이 step에 명시된 작업만 수행하라. 추가 기능이나 파일을 만들지 마라.\n"
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\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" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n"
f"---\n\n"
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, preamble: str) -> dict:
def _invoke_codex(self, step: dict, prompt: str) -> dict:
step_num, step_name = step["step"], step["name"]
step_file = self._phase_dir / f"step{step_num}.md"
if not step_file.exists():
print(f" ERROR: {step_file} not found")
sys.exit(1)
prompt = preamble + step_file.read_text(encoding="utf-8")
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",
@@ -257,14 +330,27 @@ class StepExecutor:
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})")
@@ -333,7 +419,7 @@ class StepExecutor:
# --- 실행 루프 ---
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
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")
@@ -341,26 +427,33 @@ class StepExecutor:
for attempt in range(1, self.MAX_RETRIES + 1):
index = self._read_json(self._index_file)
step_context = self._build_step_context(index)
preamble = self._build_preamble(guardrails, step_context, prev_error)
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, preamble)
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)
index = self._read_json(self._index_file)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
ts = self._stamp()
if status == "completed":
if status == "completed" and output["exitCode"] == 0:
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
@@ -380,23 +473,16 @@ class StepExecutor:
self._update_top_index("blocked")
sys.exit(2)
runtime_error = ""
if output["exitCode"] != 0:
runtime_error = (
output.get("stderr", "").strip()
or output.get("stdout", "").strip()
)
err_msg = next(
(
s.get(
"error_message",
runtime_error or "Step did not update status",
)
for s in index["steps"]
if s["step"] == step_num
),
runtime_error or "Step did not update status",
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"]:
@@ -421,7 +507,7 @@ class StepExecutor:
return False # unreachable
def _execute_all_steps(self, guardrails: str):
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)
@@ -436,7 +522,7 @@ class StepExecutor:
self._write_json(self._index_file, index)
break
self._execute_single_step(pending, guardrails)
self._execute_single_step(pending)
def _finalize(self):
index = self._read_json(self._index_file)
@@ -467,12 +553,14 @@ class StepExecutor:
def main():
parser = argparse.ArgumentParser(description="Harness Step Executor")
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
parser.add_argument("--push", action="store_true", help="Push branch after completion")
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()
except CodexEnvironmentError as exc:
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)