modify documents

This commit is contained in:
KOKO\Mimi
2026-09-06 00:52:59 +09:00
parent 97e4d7a971
commit ac84235b7a
17 changed files with 2912 additions and 1195 deletions
+221 -79
View File
@@ -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