modify harness framework
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user