modify documents
This commit is contained in:
+221
-79
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
|
||||
Harness Step Executor — phase 내 Step을 순차 실행하고 결과와 파일 소유권을 검증한다.
|
||||
|
||||
Usage:
|
||||
python scripts/execute.py <phase-dir> [--push]
|
||||
@@ -18,7 +18,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
|
||||
@@ -32,6 +32,10 @@ class CodexEnvironmentError(HarnessError):
|
||||
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
|
||||
|
||||
|
||||
class StepOwnershipError(HarnessError):
|
||||
"""승인된 Step 파일 범위 밖의 변경; 사용자 데이터를 되돌리지 않는다."""
|
||||
|
||||
|
||||
def _configure_utf8_stdio():
|
||||
"""Windows 콘솔에서도 Harness 진행 문자를 안정적으로 출력한다."""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
@@ -319,6 +323,9 @@ class StepExecutor:
|
||||
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":
|
||||
@@ -347,6 +354,76 @@ class StepExecutor:
|
||||
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]]:
|
||||
@@ -539,6 +616,10 @@ class StepExecutor:
|
||||
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()
|
||||
@@ -610,27 +691,55 @@ class StepExecutor:
|
||||
|
||||
print(f" Branch: {branch}")
|
||||
|
||||
def _commit_paths(self, paths: list[str], message: str) -> bool:
|
||||
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.
|
||||
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()}"
|
||||
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):
|
||||
changed = list(self._worktree_paths())
|
||||
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
|
||||
@@ -639,18 +748,18 @@ class StepExecutor:
|
||||
message = self.FEAT_MSG.format(
|
||||
phase=self._phase_name, num=step_num, name=step_name
|
||||
)
|
||||
if self._commit_paths(code_paths, message):
|
||||
if self._commit_paths(code_paths, message, owned_paths=owned_paths):
|
||||
print(f" Commit: {message}")
|
||||
|
||||
metadata_paths = [
|
||||
path
|
||||
for path in self._worktree_paths()
|
||||
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)
|
||||
self._commit_paths(metadata_paths, message, owned_paths=owned_paths)
|
||||
|
||||
# --- top-level index ---
|
||||
|
||||
@@ -852,6 +961,7 @@ class StepExecutor:
|
||||
self._root,
|
||||
"-",
|
||||
]
|
||||
transport_failure = None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -867,6 +977,7 @@ class StepExecutor:
|
||||
"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)
|
||||
@@ -887,6 +998,7 @@ class StepExecutor:
|
||||
"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)
|
||||
@@ -989,9 +1101,69 @@ class StepExecutor:
|
||||
|
||||
# --- 실행 루프 ---
|
||||
|
||||
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"]
|
||||
@@ -1014,8 +1186,13 @@ class StepExecutor:
|
||||
f"{self.MAX_TRANSPORT_ATTEMPTS}]"
|
||||
)
|
||||
|
||||
environment_failure = None
|
||||
with progress_indicator(tag) as pi:
|
||||
output = self._invoke_codex(step, preamble)
|
||||
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:
|
||||
@@ -1040,7 +1217,7 @@ class StepExecutor:
|
||||
raise
|
||||
|
||||
exit_code = output.get("exitCode")
|
||||
if type(exit_code) is not int:
|
||||
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,
|
||||
@@ -1052,85 +1229,50 @@ class StepExecutor:
|
||||
raise HarnessError(reason)
|
||||
|
||||
current = index["steps"][step_num]
|
||||
status = current["status"]
|
||||
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 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 action == "retry":
|
||||
if attempt < self.MAX_TRANSPORT_ATTEMPTS:
|
||||
prev_error = runtime_error
|
||||
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} — {runtime_error}"
|
||||
f"{attempt}/{self.MAX_TRANSPORT_ATTEMPTS} — {reason}"
|
||||
)
|
||||
continue
|
||||
|
||||
current["status"] = "error"
|
||||
current["error_message"] = (
|
||||
reason = (
|
||||
f"[transport failure after {self.MAX_TRANSPORT_ATTEMPTS} "
|
||||
f"attempts] {runtime_error}"
|
||||
f"attempts] {reason}"
|
||||
)
|
||||
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)
|
||||
action = "error"
|
||||
|
||||
if status == "completed":
|
||||
if action == "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)
|
||||
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
|
||||
|
||||
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)
|
||||
self._stop_step(index, step_num, action, reason)
|
||||
|
||||
return False # unreachable
|
||||
|
||||
|
||||
Reference in New Issue
Block a user