#!/usr/bin/env python3 """ Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다. Usage: python scripts/execute.py [--push] """ import argparse import contextlib import json import os import re import subprocess import sys import tempfile import threading import time import types from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Optional ROOT = Path(__file__).resolve().parent.parent class HarnessError(RuntimeError): """Executor가 안전하게 계속할 수 없는 검증 또는 실행 오류.""" class CodexEnvironmentError(HarnessError): """재시도로 해결할 수 없는 Codex CLI 환경 오류.""" def _configure_utf8_stdio(): """Windows 콘솔에서도 Harness 진행 문자를 안정적으로 출력한다.""" for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: reconfigure(encoding="utf-8") @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_TRANSPORT_ATTEMPTS = 2 CODEX_TIMEOUT_SECONDS = 1800 CODEX_SANDBOX_ENV = "FEM_HARNESS_CODEX_SANDBOX" CODEX_SANDBOX_MODES = frozenset({"workspace-write", "danger-full-access"}) PHASE_NAME = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") STEP_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") STEP_STATUSES = frozenset({"pending", "completed", "error", "blocked"}) AGENT_STEP_FIELDS = frozenset( {"status", "summary", "error_message", "blocked_reason"} ) IMPLEMENTATION_AGENT_PROFILE = Path( ".codex/agents/implementation-agent.toml" ) 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, danger_full_access_approved: bool = False, ): if not isinstance(phase_dir_name, str) or not self.PHASE_NAME.fullmatch( phase_dir_name ): raise HarnessError( "phase directory must be one safe repository-relative name" ) root_path = ROOT.resolve() self._root = str(root_path) self._phases_dir = (root_path / "phases").resolve() try: self._phases_dir.relative_to(root_path) except ValueError as exc: raise HarnessError( "phases directory must resolve inside the Harness repository root" ) from exc self._phase_dir = (self._phases_dir / phase_dir_name).resolve() try: self._phase_dir.relative_to(self._phases_dir.resolve()) except ValueError as exc: raise HarnessError( "phase directory must stay inside the repository phases directory" ) from exc self._phase_dir_name = phase_dir_name self._top_index_file = self._phases_dir / "index.json" self._auto_push = auto_push self._danger_full_access_approved = danger_full_access_approved self._validated_sandbox_mode: str | None = None self._implementation_profile_cache: tuple[str, str, str] | None = None if not self._phase_dir.is_dir(): raise HarnessError(f"phase directory not found: {self._phase_dir}") self._index_file = self._phase_dir / "index.json" if not self._index_file.exists(): raise HarnessError(f"phase index not found: {self._index_file}") if not self._top_index_file.is_file(): raise HarnessError( f"top-level phase index not found: {self._top_index_file}" ) self._read_top_index() idx = self._read_phase_index() self._project = idx["project"] self._phase_name = idx["phase"] self._total = len(idx["steps"]) def run(self): self._print_header() self._check_blockers() self._preflight_repository() self._checkout_branch() guardrails = self._load_guardrails() self._ensure_created_at() self._execute_all_steps(guardrails) self._finalize() # --- timestamps --- def _stamp(self) -> str: return datetime.now(self.TZ).strftime("%Y-%m-%dT%H:%M:%S%z") # --- JSON I/O --- def _control_scope(self, path: Path) -> Path: if path == self._top_index_file: return self._phases_dir try: path.relative_to(self._phase_dir) except ValueError as exc: raise HarnessError( f"control path is outside the selected phase: {path}" ) from exc return self._phase_dir def _validate_control_parent(self, path: Path, scope: Path) -> None: root = Path(self._root) try: resolved_scope = scope.resolve(strict=True) resolved_scope.relative_to(root) path.parent.resolve(strict=True).relative_to(resolved_scope) except (OSError, ValueError) as exc: raise HarnessError( f"control path parent escapes its trusted scope: {path}" ) from exc def _read_control_bytes(self, path: Path) -> bytes: scope = self._control_scope(path) self._validate_control_parent(path, scope) try: metadata = path.lstat() resolved = path.resolve(strict=True) resolved.relative_to(scope.resolve(strict=True)) except (OSError, ValueError) as exc: raise HarnessError( f"protected control file is missing or escapes its scope: {path}" ) from exc if path.is_symlink() or not path.is_file() or metadata.st_nlink != 1: raise HarnessError( f"protected control file must be a non-link regular file: {path}" ) try: return path.read_bytes() except OSError as exc: raise HarnessError( f"protected control file could not be read: {path}: {exc}" ) from exc def _atomic_write_control_bytes(self, path: Path, contents: bytes) -> None: scope = self._control_scope(path) self._validate_control_parent(path, scope) if path.exists() and not path.is_symlink() and not path.is_file(): raise HarnessError( f"control output path is not a regular file: {path}" ) temporary_path: Path | None = None try: with tempfile.NamedTemporaryFile( mode="wb", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_path = Path(handle.name) handle.write(contents) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, path) except OSError as exc: raise HarnessError( f"control output could not be written atomically: {path}: {exc}" ) from exc finally: if temporary_path is not None and temporary_path.exists(): temporary_path.unlink() def _read_json(self, p: Path) -> dict: try: data = json.loads(self._read_control_bytes(p).decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise HarnessError(f"invalid JSON file {p}: {exc}") from exc if not isinstance(data, dict): raise HarnessError(f"JSON root must be an object: {p}") return data def _write_json(self, p: Path, data: dict): contents = json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8") self._atomic_write_control_bytes(p, contents) def _read_phase_index(self) -> dict: index = self._read_json(self._index_file) self._validate_phase_index(index) return index def _read_top_index(self) -> dict: index = self._read_json(self._top_index_file) phases = index.get("phases") if not isinstance(phases, list) or not phases: raise HarnessError("top-level phase index requires a non-empty phases array") selected_count = 0 seen: set[str] = set() for position, phase in enumerate(phases): if not isinstance(phase, dict): raise HarnessError( f"top-level phase entry {position} must be an object" ) directory = phase.get("dir") if not isinstance(directory, str) or not self.PHASE_NAME.fullmatch( directory ): raise HarnessError( f"top-level phase entry {position} has an invalid directory" ) if directory in seen: raise HarnessError( f"top-level phase '{directory}' must appear exactly once" ) seen.add(directory) if directory == self._phase_dir_name: selected_count += 1 if phase.get("status") not in self.STEP_STATUSES: raise HarnessError( f"top-level phase entry {position} has an invalid status" ) if selected_count != 1: raise HarnessError( f"selected phase '{self._phase_dir_name}' must appear exactly once " "in the top-level phase index" ) return index def _validate_phase_index(self, index: dict) -> None: project = index.get("project") if not isinstance(project, str) or not project.strip(): raise HarnessError("phase index project must be a non-empty string") phase = index.get("phase") if phase != self._phase_dir_name: raise HarnessError( "phase index phase must match its phase directory name" ) steps = index.get("steps") if not isinstance(steps, list) or not steps: raise HarnessError("phase index steps must be a non-empty array") expected_steps = list(range(len(steps))) actual_steps = [step.get("step") if isinstance(step, dict) else None for step in steps] if actual_steps != expected_steps: raise HarnessError("phase step numbers must be contiguous from zero") state = "completed-prefix" for expected, step in enumerate(steps): if not isinstance(step, dict): raise HarnessError(f"phase step {expected} must be an object") name = step.get("name") if not isinstance(name, str) or not self.STEP_NAME.fullmatch(name): raise HarnessError( f"phase step {expected} name must be a kebab-case slug" ) status = step.get("status") if status not in self.STEP_STATUSES: raise HarnessError(f"phase step {expected} has invalid status") if status == "completed": if state != "completed-prefix": raise HarnessError("phase step status order is invalid") if not isinstance(step.get("summary"), str) or not step["summary"].strip(): raise HarnessError( f"completed phase step {expected} requires summary" ) elif status == "pending": if state == "completed-prefix": state = "pending-suffix" else: if state != "completed-prefix": raise HarnessError("phase step status order is invalid") detail_key = "error_message" if status == "error" else "blocked_reason" if not isinstance(step.get(detail_key), str) or not step[detail_key].strip(): raise HarnessError( f"{status} phase step {expected} requires {detail_key}" ) state = "terminal-then-pending" if state == "terminal-then-pending" and status == "pending": state = "pending-suffix" step_file = self._phase_dir / f"step{expected}.md" if not step_file.is_file(): raise HarnessError(f"phase step file not found: {step_file}") def _snapshot_phase_control_files( self, index: dict ) -> tuple[bytes, dict[str, bytes]]: expected_names = {f"step{step['step']}.md" for step in index["steps"]} current_names = { path.name for path in self._phase_dir.glob("step*.md") if path.is_file() } if current_names != expected_names: raise HarnessError( "protected phase control file inventory does not match the phase index" ) try: top_index = self._read_control_bytes(self._top_index_file) step_files = { f"step{step['step']}.md": ( self._read_control_bytes( self._phase_dir / f"step{step['step']}.md" ) ) for step in index["steps"] } except HarnessError as exc: raise HarnessError( f"protected phase control file could not be read: {exc}" ) from exc return top_index, step_files def _validate_phase_control_files( self, snapshot: tuple[bytes, dict[str, bytes]] ) -> None: expected_top, expected_steps = snapshot try: current_top = self._read_control_bytes(self._top_index_file) current_names = { path.name for path in self._phase_dir.glob("step*.md") if path.is_file() } current_steps = { name: self._read_control_bytes(self._phase_dir / name) for name in current_names } except HarnessError as exc: raise HarnessError( f"protected phase control file changed or disappeared: {exc}" ) from exc if current_top != expected_top or current_steps != expected_steps: raise HarnessError( "Codex modified a protected phase control file; only the current " "Step outcome in the phase index may change" ) def _record_untrusted_step_result( self, index_before: dict, current_step: int, classification: str, reason: str, control_snapshot: tuple[bytes, dict[str, bytes]], ) -> None: expected_top, expected_steps = control_snapshot self._top_index_file.parent.mkdir(parents=True, exist_ok=True) self._atomic_write_control_bytes(self._top_index_file, expected_top) self._phase_dir.mkdir(parents=True, exist_ok=True) for name, contents in expected_steps.items(): self._atomic_write_control_bytes(self._phase_dir / name, contents) recovered = json.loads(json.dumps(index_before)) current = recovered["steps"][current_step] self._clear_step_outcome(current) current["status"] = "error" current["error_message"] = f"[{classification}] {reason}" current["failed_at"] = self._stamp() self._write_json(self._index_file, recovered) self._update_top_index("error") def _validate_step_index_mutation( self, before: dict, after: dict, current_step: int ) -> None: before_metadata = {key: value for key, value in before.items() if key != "steps"} after_metadata = {key: value for key, value in after.items() if key != "steps"} if before_metadata != after_metadata: raise HarnessError( "Codex modified protected phase index metadata" ) before_steps = before["steps"] after_steps = after["steps"] if len(before_steps) != len(after_steps): raise HarnessError("Codex modified the protected Step inventory") for position, (old_step, new_step) in enumerate( zip(before_steps, after_steps) ): if position != current_step: if old_step != new_step: raise HarnessError( f"Codex modified non-current Step {position}" ) continue old_fixed = { key: value for key, value in old_step.items() if key not in self.AGENT_STEP_FIELDS } new_fixed = { key: value for key, value in new_step.items() if key not in self.AGENT_STEP_FIELDS } if old_fixed != new_fixed: raise HarnessError( "Codex modified immutable metadata on the current Step" ) # --- 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" ) def _run_git_checked(self, *args) -> subprocess.CompletedProcess: result = self._run_git(*args) if result.returncode != 0: diagnostic = (result.stderr or result.stdout).strip() message = f"git {args[0]} failed" if diagnostic: message = f"{message}: {diagnostic}" raise HarnessError(message) return result def _git_execution_identity(self) -> tuple[str, str]: branch = self._run_git_checked( "symbolic-ref", "--quiet", "HEAD" ).stdout.strip() head = self._run_git_checked( "rev-parse", "--verify", "HEAD" ).stdout.strip() if not branch or not head: raise HarnessError("Git branch and HEAD identity must be non-empty") return branch, head def _worktree_paths(self) -> tuple[str, ...]: result = self._run_git_checked( "status", "--porcelain=v1", "-z", "--untracked-files=all" ) paths: list[str] = [] records = result.stdout.split("\0") position = 0 while position < len(records): record = records[position] position += 1 if not record: continue if len(record) < 4 or record[2] != " ": raise HarnessError("git status returned an invalid porcelain record") status = record[:2] paths.append(record[3:].replace("\\", "/")) if "R" in status or "C" in status: if position >= len(records) or not records[position]: raise HarnessError( "git status returned an incomplete rename/copy record" ) paths.append(records[position].replace("\\", "/")) position += 1 return tuple(dict.fromkeys(paths)) def _is_phase_owned_path(self, path: str) -> bool: phase_prefix = f"phases/{self._phase_dir_name}/" declared = { "phases/index.json", f"{phase_prefix}index.json", *( f"{phase_prefix}step{step_num}.md" for step_num in range(self._total) ), } return path in declared or self._is_step_output_path(path) def _is_step_output_path(self, path: str) -> bool: phase_prefix = f"phases/{self._phase_dir_name}/" if not path.startswith(phase_prefix): return False name = path.removeprefix(phase_prefix) match = re.fullmatch(r"step(\d+)-output\.json", name) return match is not None and int(match.group(1)) < self._total def _preflight_repository(self) -> None: self._implementation_agent_profile() top_level_result = self._run_git_checked("rev-parse", "--show-toplevel") raw_top_level = top_level_result.stdout.strip() if not raw_top_level or Path(raw_top_level).resolve() != Path(self._root): raise HarnessError( "Git top-level must exactly match the Harness repository root" ) mode = self._codex_sandbox_mode() paths = self._worktree_paths() if mode == "danger-full-access": if paths: raise CodexEnvironmentError( "danger-full-access requires a clean worktree" ) return unrelated = [path for path in paths if not self._is_phase_owned_path(path)] if unrelated: preview = ", ".join(unrelated[:5]) raise HarnessError( "Harness requires a clean worktree outside the selected phase; " f"unrelated changes: {preview}" ) def _is_isolated_worktree(self) -> bool: git_dir = Path( self._run_git_checked("rev-parse", "--absolute-git-dir").stdout.strip() ).resolve() common_raw = self._run_git_checked("rev-parse", "--git-common-dir").stdout.strip() common_dir = Path(common_raw) if not common_dir.is_absolute(): common_dir = Path(self._root) / common_dir return git_dir != common_dir.resolve() def _checkout_branch(self): branch = f"feat-{self._phase_name}" branch_ref = f"refs/heads/{branch}" current = self._run_git("symbolic-ref", "--quiet", "HEAD") if current.returncode == 0 and current.stdout.strip() == branch_ref: return exists = self._run_git( "show-ref", "--verify", "--quiet", branch_ref ) if exists.returncode == 0: switched = self._run_git("switch", branch) elif exists.returncode == 1: switched = self._run_git("switch", "-c", branch) else: raise HarnessError( f"git show-ref failed for branch '{branch}': " f"{(exists.stderr or exists.stdout).strip()}" ) if switched.returncode != 0: raise HarnessError( f"git switch failed for branch '{branch}': " f"{(switched.stderr or switched.stdout).strip()}" ) verified = self._run_git_checked( "symbolic-ref", "--quiet", "HEAD" ) if verified.stdout.strip() != branch_ref: raise HarnessError( f"Git did not enter the expected local branch '{branch}'; " f"actual={verified.stdout.strip()!r}" ) print(f" Branch: {branch}") def _commit_paths(self, paths: list[str], message: str) -> bool: if not paths: return False # Codex may stage files even though the prompt tells it not to. Reset only # the index so the following path-scoped add controls the commit content; # working-tree data is preserved. This also expands staged renames back to # a deletion/addition pair, allowing both porcelain pathnames to be staged. self._run_git_checked("reset", "--quiet", "HEAD", "--", ".") self._run_git_checked("add", "-A", "--", *sorted(paths)) diff = self._run_git("diff", "--cached", "--quiet") if diff.returncode == 0: return False if diff.returncode != 1: raise HarnessError( f"git diff failed: {(diff.stderr or diff.stdout).strip()}" ) self._run_git_checked("commit", "-m", message) return True def _commit_step(self, step_num: int, step_name: str): changed = list(self._worktree_paths()) code_paths = [ path for path in changed if not self._is_phase_owned_path(path) ] message = self.FEAT_MSG.format( phase=self._phase_name, num=step_num, name=step_name ) if self._commit_paths(code_paths, message): print(f" Commit: {message}") metadata_paths = [ path for path in self._worktree_paths() if self._is_phase_owned_path(path) and not self._is_step_output_path(path) ] message = self.CHORE_MSG.format( phase=self._phase_name, num=step_num ) self._commit_paths(metadata_paths, message) # --- top-level index --- def _update_top_index(self, status: str): if status not in {"pending", "completed", "error", "blocked"}: raise HarnessError(f"invalid top-level phase status: {status}") top = self._read_top_index() phases = top["phases"] ts = self._stamp() matched = False for phase in phases: if not isinstance(phase, dict): raise HarnessError("top-level phase entry must be an object") if phase.get("dir") == self._phase_dir_name: matched = True phase["status"] = status ts_key = {"completed": "completed_at", "error": "failed_at", "blocked": "blocked_at"}.get(status) for key in ("completed_at", "failed_at", "blocked_at"): if key != ts_key: phase.pop(key, None) if ts_key: phase[ts_key] = ts break if not matched: # Defensive: _read_top_index already enforces this invariant. raise HarnessError("selected phase is missing from the top-level phase index") self._write_json(self._top_index_file, top) # --- 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 "" @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 _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" ) _, _, role_instructions = self._implementation_agent_profile() return ( f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n" f"## Implementation Agent role\n\n{role_instructions}\n\n---\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"3. 기존 테스트를 깨뜨리지 마라.\n" f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n" f"5. /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"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n" f"---\n\n" ) # --- Codex 호출 --- def _codex_sandbox_mode(self) -> str: if self._validated_sandbox_mode is not None: return self._validated_sandbox_mode mode = os.environ.get(self.CODEX_SANDBOX_ENV, "workspace-write") if mode not in self.CODEX_SANDBOX_MODES: allowed = ", ".join(sorted(self.CODEX_SANDBOX_MODES)) raise CodexEnvironmentError( f"{self.CODEX_SANDBOX_ENV} must be one of: {allowed}" ) if mode == "danger-full-access": if not self._danger_full_access_approved: raise CodexEnvironmentError( "danger-full-access requires explicit authorization through " "--approve-danger-full-access" ) if not self._is_isolated_worktree(): raise CodexEnvironmentError( "danger-full-access requires an isolated linked worktree" ) if self._worktree_paths(): raise CodexEnvironmentError( "danger-full-access requires a clean worktree" ) self._validated_sandbox_mode = mode return mode def _implementation_agent_profile(self) -> tuple[str, str, str]: if self._implementation_profile_cache is not None: return self._implementation_profile_cache profile_path = Path(self._root) / self.IMPLEMENTATION_AGENT_PROFILE try: profile_text = profile_path.read_text(encoding="utf-8") except OSError as exc: raise CodexEnvironmentError( f"Implementation Agent profile을 읽을 수 없습니다: {profile_path}" ) from exc settings = {} for line in profile_text.splitlines(): key, separator, value = line.partition("=") key = key.strip() if separator and key in {"model", "model_reasoning_effort"}: try: settings[key] = json.loads(value.strip()) except json.JSONDecodeError as exc: raise CodexEnvironmentError( f"Implementation Agent profile의 {key} 값이 올바른 문자열이 아닙니다." ) from exc model = settings.get("model") reasoning_effort = settings.get("model_reasoning_effort") if not isinstance(model, str) or not isinstance(reasoning_effort, str): raise CodexEnvironmentError( "Implementation Agent profile에 model과 model_reasoning_effort 문자열이 필요합니다." ) match = re.search( r'^developer_instructions\s*=\s*"""(.*?)"""\s*$', profile_text, re.MULTILINE | re.DOTALL, ) if match is None or not match.group(1).strip(): raise CodexEnvironmentError( "Implementation Agent profile에 developer_instructions가 필요합니다." ) instructions = match.group(1).strip() self._implementation_profile_cache = ( model, reasoning_effort, instructions, ) return self._implementation_profile_cache def _implementation_agent_settings(self) -> tuple[str, str]: model, reasoning_effort, _ = self._implementation_agent_profile() return model, reasoning_effort @staticmethod def _process_text(value: str | bytes | None) -> str: if value is None: return "" if isinstance(value, bytes): return value.decode("utf-8", errors="replace") return value def _invoke_codex(self, step: dict, preamble: 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) try: step_text = self._read_control_bytes(step_file).decode("utf-8") except UnicodeDecodeError as exc: raise HarnessError(f"Step instruction must be valid UTF-8: {step_file}") from exc prompt = preamble + step_text sandbox_mode = self._codex_sandbox_mode() model, reasoning_effort = self._implementation_agent_settings() command = [ "codex", "exec", "--disable", "unified_exec", "--json", "--model", model, "--config", f"model_reasoning_effort={json.dumps(reasoning_effort)}", "--sandbox", sandbox_mode, "--dangerously-bypass-hook-trust", "--cd", self._root, "-", ] try: result = subprocess.run( command, cwd=self._root, input=prompt, capture_output=True, text=True, encoding="utf-8", timeout=self.CODEX_TIMEOUT_SECONDS, ) except FileNotFoundError as exc: raise CodexEnvironmentError( "Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요." ) from exc except subprocess.TimeoutExpired as exc: timeout_message = f"Codex timed out after {exc.timeout} seconds." stdout = self._process_text(exc.stdout) stderr = self._process_text(exc.stderr) if stderr: stderr = f"{timeout_message}\n{stderr}" else: stderr = timeout_message result = subprocess.CompletedProcess( command, 124, stdout=stdout, stderr=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]: diagnostic = ( f"{output.get('stderr', '')}\n{output.get('stdout', '')}" ).lower() if "orchestrator_helper_launch_failed" in diagnostic: return "Codex Windows sandbox helper를 실행할 수 없습니다." if output.get("exitCode", 0) == 0: return None 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_phase_index() current = next( (step for step in index["steps"] if step["status"] != "completed"), None, ) if current is None or current["status"] == "pending": return if current["status"] == "error": print(f"\n ✗ Step {current['step']} ({current['name']}) failed.") print(f" Error: {current['error_message']}") print(" Coordinator must classify the workflow correction before retry.") sys.exit(1) print(f"\n ⏸ Step {current['step']} ({current['name']}) blocked.") print(f" Reason: {current['blocked_reason']}") print(" Resolve the blocker and reset the Step to pending before retry.") sys.exit(2) @staticmethod def _clear_step_outcome(step: dict, keep: frozenset[str] = frozenset()) -> None: for key in ( "summary", "completed_at", "error_message", "failed_at", "blocked_reason", "blocked_at", ): if key not in keep: step.pop(key, None) def _ensure_created_at(self): index = self._read_phase_index() changed = False has_pending = any(step["status"] == "pending" for step in index["steps"]) if "created_at" not in index: index["created_at"] = self._stamp() changed = True if has_pending: if "completed_at" in index: index.pop("completed_at") changed = True for step in index["steps"]: if step["status"] != "pending": continue before = dict(step) self._clear_step_outcome(step) if step != before: changed = True if changed: self._write_json(self._index_file, index) if has_pending: top = self._read_top_index() selected = next( phase for phase in top["phases"] if phase["dir"] == self._phase_dir_name ) if selected["status"] != "pending" or any( key in selected for key in ("completed_at", "failed_at", "blocked_at") ): self._update_top_index("pending") # --- 실행 루프 --- def _execute_single_step(self, step: dict, guardrails: str) -> bool: """Step을 실행한다. 자동 재시도는 Codex transport 실패에만 적용한다.""" step_num, step_name = step["step"], step["name"] done = sum( 1 for item in self._read_phase_index()["steps"] if item["status"] == "completed" ) prev_error = None for attempt in range(1, self.MAX_TRANSPORT_ATTEMPTS + 1): index = self._read_phase_index() step_context = self._build_step_context(index) preamble = self._build_preamble(guardrails, step_context, prev_error) control_snapshot = self._snapshot_phase_control_files(index) index_before_invocation = index git_identity = self._git_execution_identity() tag = f"Step {step_num}/{self._total - 1} ({done} done): {step_name}" if attempt > 1: tag += ( f" [transport retry {attempt}/" f"{self.MAX_TRANSPORT_ATTEMPTS}]" ) with progress_indicator(tag) as pi: output = self._invoke_codex(step, preamble) elapsed = int(pi.elapsed) try: if self._git_execution_identity() != git_identity: raise HarnessError( "Codex changed the Git branch or HEAD; Git history is " "Executor-owned" ) self._validate_phase_control_files(control_snapshot) index = self._read_phase_index() self._validate_step_index_mutation( index_before_invocation, index, step_num ) except HarnessError as exc: self._record_untrusted_step_result( index_before_invocation, step_num, "control violation", str(exc), control_snapshot, ) raise exit_code = output.get("exitCode") if type(exit_code) is not int: reason = "Codex output requires an integer exitCode" self._record_untrusted_step_result( index_before_invocation, step_num, "protocol violation", reason, control_snapshot, ) raise HarnessError(reason) current = index["steps"][step_num] status = current["status"] ts = self._stamp() if exit_code != 0: runtime_error = ( output.get("stderr", "").strip() or output.get("stdout", "").strip() or f"Codex exited with code {exit_code}" )[-4000:] current["status"] = "pending" self._clear_step_outcome(current) self._write_json(self._index_file, index) environment_failure = self._codex_environment_failure(output) if environment_failure: raise CodexEnvironmentError(environment_failure) if attempt < self.MAX_TRANSPORT_ATTEMPTS: prev_error = runtime_error print( f" ↻ Step {step_num}: transport retry " f"{attempt}/{self.MAX_TRANSPORT_ATTEMPTS} — {runtime_error}" ) continue current["status"] = "error" current["error_message"] = ( f"[transport failure after {self.MAX_TRANSPORT_ATTEMPTS} " f"attempts] {runtime_error}" ) current["failed_at"] = ts self._write_json(self._index_file, index) self._update_top_index("error") print( f" ✗ Step {step_num}: transport failed after " f"{self.MAX_TRANSPORT_ATTEMPTS} attempts [{elapsed}s]" ) print(f" Error: {runtime_error}") sys.exit(1) if status == "completed": self._clear_step_outcome( current, frozenset({"summary", "completed_at"}) ) current["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": self._clear_step_outcome( current, frozenset({"blocked_reason", "blocked_at"}) ) current["blocked_at"] = ts self._write_json(self._index_file, index) print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]") print(f" Reason: {current['blocked_reason']}") self._update_top_index("blocked") sys.exit(2) if status == "error": self._clear_step_outcome( current, frozenset({"error_message", "failed_at"}) ) current["failed_at"] = ts self._write_json(self._index_file, index) self._update_top_index("error") print(f" ✗ Step {step_num}: {step_name} failed [{elapsed}s]") print(f" Error: {current['error_message']}") sys.exit(1) current["status"] = "error" self._clear_step_outcome(current) current["error_message"] = "Step did not update its workflow status" current["failed_at"] = ts self._write_json(self._index_file, index) self._update_top_index("error") print(f" ✗ Step {step_num}: status was not updated [{elapsed}s]") sys.exit(1) return False # unreachable def _execute_all_steps(self, guardrails: str): while True: index = self._read_phase_index() 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, guardrails) def _finalize(self): index = self._read_phase_index() if any(step["status"] != "completed" for step in index["steps"]): raise HarnessError("phase cannot finalize before every Step is completed") changed = list(self._worktree_paths()) unexpected = [ path for path in changed if not self._is_phase_owned_path(path) ] if unexpected: raise HarnessError( "uncommitted implementation paths remain before finalization: " + ", ".join(unexpected[:5]) ) index["completed_at"] = self._stamp() self._write_json(self._index_file, index) self._update_top_index("completed") changed = list(self._worktree_paths()) metadata_paths = [ path for path in changed if not self._is_step_output_path(path) ] msg = f"chore({self._phase_name}): mark phase completed" if self._commit_paths(metadata_paths, msg): print(f" ✓ {msg}") if self._auto_push: branch = f"feat-{self._phase_name}" self._run_git_checked("push", "-u", "origin", branch) print(f" ✓ Pushed to origin/{branch}") print(f"\n{'='*60}") print(f" Phase '{self._phase_name}' completed!") print(f"{'='*60}") def main(): _configure_utf8_stdio() 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") parser.add_argument( "--approve-danger-full-access", action="store_true", help=( "Explicitly authorize danger-full-access when the matching sandbox " "environment setting is used in a clean linked worktree" ), ) args = parser.parse_args() try: StepExecutor( args.phase_dir, auto_push=args.push, danger_full_access_approved=args.approve_danger_full_access, ).run() except HarnessError as exc: print(f"ERROR: {exc}") sys.exit(1) if __name__ == "__main__": main()