Files
FESADev/scripts/execute.py
T

1361 lines
54 KiB
Python

#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 Step을 순차 실행하고 결과와 파일 소유권을 검증한다.
Usage:
python scripts/execute.py <phase-dir> [--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, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class HarnessError(RuntimeError):
"""Executor가 안전하게 계속할 수 없는 검증 또는 실행 오류."""
class CodexEnvironmentError(HarnessError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class StepOwnershipError(HarnessError):
"""승인된 Step 파일 범위 밖의 변경; 사용자 데이터를 되돌리지 않는다."""
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 = 14400
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 "owned_paths" in step:
self._step_owned_paths(step)
self._validate_attempt_history(step)
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}")
@staticmethod
def _path_key(path: str) -> str:
return os.path.normcase(path).replace("\\", "/")
def _step_owned_paths(self, step: dict) -> tuple[str, ...]:
paths = step.get("owned_paths")
if not isinstance(paths, list):
raise HarnessError(f"Step {step['step']} requires an owned_paths file array before execution")
normalized = []
seen = set()
for path in paths:
if not isinstance(path, str):
raise HarnessError("owned_paths entries must be repository-relative file paths")
path = path.replace("\\", "/")
parts = path.split("/")
if (
not path or PureWindowsPath(path).drive or path.startswith("/")
or any(part in {"", ".", ".."} or part.endswith((".", " ")) for part in parts)
or any(ord(char) < 32 or char in ':*?[]<>"|' for char in path)
or any(part.casefold() == ".git" or PureWindowsPath(part).is_reserved() for part in parts)
or parts[0].casefold() == "phases"
):
raise HarnessError(f"owned_paths requires an exact non-control relative file path: {path!r}")
key = self._path_key(path)
if key in seen:
raise HarnessError(f"owned_paths contains duplicate/aliased paths: {path}")
seen.add(key)
normalized.append(path)
return tuple(normalized)
def _validate_owned_file(self, path: str) -> None:
root = Path(self._root)
candidate = root / path
try:
resolved = candidate.resolve()
resolved.relative_to(root)
if resolved != candidate or candidate.is_symlink() or candidate.is_dir():
raise ValueError("directory or linked path")
if candidate.exists() and candidate.lstat().st_nlink != 1:
raise ValueError("hard-linked file")
except (OSError, RuntimeError, ValueError) as exc:
raise HarnessError(f"owned_paths must name non-linked files inside the repository: {path}") from exc
def _validate_attempt_history(self, step: dict) -> None:
if "attempt_history" not in step:
return
history = step["attempt_history"]
if not isinstance(history, list):
raise HarnessError("attempt_history must be an Executor-owned array")
for number, record in enumerate(history, 1):
if (
not isinstance(record, dict)
or type(record.get("attempt")) is not int or record["attempt"] != number
or "exit_code" not in record
or (record["exit_code"] is not None and type(record["exit_code"]) is not int)
or not isinstance(record.get("reported_status"), str)
or record["reported_status"] not in self.STEP_STATUSES
or not isinstance(record.get("recorded_at"), str) or not record["recorded_at"]
or not isinstance(record.get("reported_reason"), str)
or not isinstance(record.get("reason"), str)
or not isinstance(record.get("diagnostic"), str)
or not isinstance(record.get("classification"), str)
or record["classification"] not in {
"environment", "reported-blocked", "reported-error", "completed",
"inconsistent-result", "transport-timeout", "process-error", "missing-result",
"ownership-violation", "commit-error",
}
):
raise HarnessError(f"attempt_history contains an invalid record at attempt {number}")
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:
for step in self._read_phase_index()["steps"]:
if step["status"] == "pending":
for path in self._step_owned_paths(step):
self._validate_owned_file(path)
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 _assert_step_scope(self, owned_paths: tuple[str, ...]) -> tuple[str, ...]:
allowed = {self._path_key(path) for path in owned_paths}
changed = self._worktree_paths()
unexpected = [
path for path in changed
if not self._is_phase_owned_path(path) and self._path_key(path) not in allowed
]
if unexpected:
raise StepOwnershipError("Step ownership violation: " + ", ".join(unexpected))
try:
for path in owned_paths:
self._validate_owned_file(path)
except HarnessError as exc:
raise StepOwnershipError(str(exc)) from exc
return changed
def _commit_paths(
self, paths: list[str], message: str, *, owned_paths: tuple[str, ...] = ()
) -> bool:
validated_paths = self._assert_step_scope(owned_paths)
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.
if validated_paths:
self._run_git_checked(
"--literal-pathspecs", "reset", "--quiet", "HEAD", "--", *sorted(validated_paths)
)
self._run_git_checked("--literal-pathspecs", "add", "-A", "--", *sorted(paths))
staged = self._run_git_checked(
"diff", "--cached", "--name-only", "--no-renames", "-z"
).stdout
selected = {self._path_key(path) for path in paths}
unexpected = [
path for path in staged.split("\0")
if path and self._path_key(path) not in selected
]
if unexpected:
raise StepOwnershipError("Unexpected staged paths before commit: " + ", ".join(unexpected))
self._assert_step_scope(owned_paths)
if not staged:
return False
self._run_git_checked("commit", "-m", message)
return True
def _commit_step(self, step_num: int, step_name: str, owned_paths: tuple[str, ...]):
changed = self._assert_step_scope(owned_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, owned_paths=owned_paths):
print(f" Commit: {message}")
metadata_paths = [
path
for path in self._assert_step_scope(owned_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, owned_paths=owned_paths)
# --- 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,
"-",
]
transport_failure = None
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:
transport_failure = "timeout"
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,
"transportFailure": transport_failure,
}
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 _classify_attempt(
self, current: dict, output: dict, environment_failure: Optional[str]
) -> tuple[str, str, str]:
"""Return local classification, action and reason; never infer retry from exit alone."""
environment_failure = environment_failure or self._codex_environment_failure(output)
if environment_failure:
return "environment", "blocked", environment_failure
status = current["status"]
if status == "blocked":
return "reported-blocked", "blocked", current["blocked_reason"]
if status == "error":
return "reported-error", "error", current["error_message"]
exit_code = output["exitCode"]
if status == "completed":
if exit_code == 0:
return "completed", "completed", current["summary"]
return (
"inconsistent-result", "error",
f"Step reported completed but Codex exited with code {exit_code}",
)
diagnostic = (output.get("stderr", "").strip() or output.get("stdout", "").strip())[-4000:]
if output.get("transportFailure") == "timeout":
return "transport-timeout", "retry", diagnostic
if exit_code != 0:
return "process-error", "error", diagnostic or f"Codex exited with code {exit_code}"
return "missing-result", "error", "Step did not update its workflow status"
def _record_attempt(self, current: dict, output: dict, classification: str, reason: str) -> None:
history = current.setdefault("attempt_history", [])
field = {"completed": "summary", "error": "error_message", "blocked": "blocked_reason"}.get(current["status"])
history.append({
"attempt": len(history) + 1,
"recorded_at": self._stamp(),
"exit_code": output["exitCode"],
"reported_status": current["status"],
"reported_reason": current.get(field, "") if field else "",
"classification": classification,
"reason": reason,
"diagnostic": (output.get("stderr", "").strip() or output.get("stdout", "").strip())[-4000:],
})
def _stop_step(self, index: dict, step_num: int, status: str, reason: str) -> None:
current = index["steps"][step_num]
current["attempt_history"][-1]["reason"] = reason
self._clear_step_outcome(current)
current["status"] = status
field, timestamp, exit_code = (
("blocked_reason", "blocked_at", 2) if status == "blocked"
else ("error_message", "failed_at", 1)
)
current[field] = reason
current[timestamp] = self._stamp()
self._write_json(self._index_file, index)
self._update_top_index(status)
print(f" Step {step_num}: {status} — {reason}")
sys.exit(exit_code)
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
"""Step을 실행한다. 자동 재시도는 Codex transport 실패에만 적용한다."""
step_num, step_name = step["step"], step["name"]
owned_paths = self._step_owned_paths(self._read_phase_index()["steps"][step_num])
for path in owned_paths:
self._validate_owned_file(path)
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}]"
)
environment_failure = None
with progress_indicator(tag) as pi:
try:
output = self._invoke_codex(step, preamble)
except CodexEnvironmentError as exc:
environment_failure = str(exc)
output = {"exitCode": None, "stdout": "", "stderr": str(exc)}
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 and environment_failure is None:
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]
ts = self._stamp()
try:
self._assert_step_scope(owned_paths)
except StepOwnershipError as exc:
self._record_attempt(current, output, "ownership-violation", str(exc))
self._stop_step(index, step_num, "error", str(exc))
classification, action, reason = self._classify_attempt(
current, output, environment_failure
)
self._record_attempt(current, output, classification, reason)
if action == "retry":
if attempt < self.MAX_TRANSPORT_ATTEMPTS:
self._clear_step_outcome(current)
self._write_json(self._index_file, index)
prev_error = reason
print(
f" ↻ Step {step_num}: transport retry "
f"{attempt}/{self.MAX_TRANSPORT_ATTEMPTS} — {reason}"
)
continue
reason = (
f"[transport failure after {self.MAX_TRANSPORT_ATTEMPTS} "
f"attempts] {reason}"
)
action = "error"
if action == "completed":
self._clear_step_outcome(
current, frozenset({"summary", "completed_at"})
)
current["completed_at"] = ts
self._write_json(self._index_file, index)
try:
self._commit_step(step_num, step_name, owned_paths)
except HarnessError as exc:
current["attempt_history"][-1]["classification"] = (
"ownership-violation" if isinstance(exc, StepOwnershipError) else "commit-error"
)
self._stop_step(index, step_num, "error", str(exc))
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
return True
self._stop_step(index, step_num, action, reason)
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()