modify documents
This commit is contained in:
+297
-9
@@ -1,18 +1,33 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import execute
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_sandbox_override(monkeypatch):
|
||||
monkeypatch.delenv("FEM_HARNESS_CODEX_SANDBOX", raising=False)
|
||||
|
||||
|
||||
def write_top_index(phase_dir):
|
||||
(phase_dir.parent / "index.json").write_text(
|
||||
json.dumps({"phases": [{"dir": phase_dir.name, "status": "pending"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_implementation_agent_profile(
|
||||
root, *, model="gpt-5.6-terra", reasoning="xhigh"
|
||||
):
|
||||
profile = root / ".codex" / "agents" / "implementation-agent.toml"
|
||||
profile.parent.mkdir(parents=True)
|
||||
profile.write_text(
|
||||
f'model = "{model}"\nmodel_reasoning_effort = "{reasoning}"\n',
|
||||
f'model = "{model}"\nmodel_reasoning_effort = "{reasoning}"\n'
|
||||
'developer_instructions = """Implementation Agent test role."""\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -20,6 +35,7 @@ def write_implementation_agent_profile(
|
||||
def test_invoke_codex_uses_utf8_for_unicode_prompt(tmp_path, monkeypatch):
|
||||
phase_dir = tmp_path / "phases" / "unicode-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -57,6 +73,7 @@ def test_invoke_codex_uses_implementation_agent_command_contract(
|
||||
):
|
||||
phase_dir = tmp_path / "phases" / "agent-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -95,6 +112,7 @@ def test_invoke_codex_accepts_explicit_danger_full_access_override(
|
||||
):
|
||||
phase_dir = tmp_path / "phases" / "sandbox-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -111,14 +129,23 @@ def test_invoke_codex_accepts_explicit_danger_full_access_override(
|
||||
invocation = {}
|
||||
|
||||
def capture_run(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
responses = {
|
||||
("rev-parse", "--absolute-git-dir"): str(tmp_path / ".git/worktrees/sandbox"),
|
||||
("rev-parse", "--git-common-dir"): str(tmp_path / ".git"),
|
||||
("status", "--porcelain=v1", "-z", "--untracked-files=all"): "",
|
||||
}
|
||||
return subprocess.CompletedProcess(
|
||||
command, 0, stdout=responses[tuple(command[1:])], stderr=""
|
||||
)
|
||||
invocation["command"] = command
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(execute, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(execute.subprocess, "run", capture_run)
|
||||
monkeypatch.setenv("FESA_HARNESS_CODEX_SANDBOX", "danger-full-access")
|
||||
monkeypatch.setenv("FEM_HARNESS_CODEX_SANDBOX", "danger-full-access")
|
||||
|
||||
executor = execute.StepExecutor("sandbox-phase")
|
||||
executor = execute.StepExecutor("sandbox-phase", danger_full_access_approved=True)
|
||||
executor._invoke_codex({"step": 0, "name": "sandbox"}, "")
|
||||
|
||||
sandbox_index = invocation["command"].index("--sandbox")
|
||||
@@ -128,6 +155,7 @@ def test_invoke_codex_accepts_explicit_danger_full_access_override(
|
||||
def test_invoke_codex_timeout_writes_structured_failure(tmp_path, monkeypatch):
|
||||
phase_dir = tmp_path / "phases" / "timeout-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -153,6 +181,7 @@ def test_invoke_codex_timeout_writes_structured_failure(tmp_path, monkeypatch):
|
||||
output = executor._invoke_codex({"step": 0, "name": "timeout"}, "")
|
||||
|
||||
assert output["exitCode"] == 124
|
||||
assert output["transportFailure"] == "timeout"
|
||||
assert output["stdout"] == "partial stdout"
|
||||
assert "Codex timed out after 1800 seconds." in output["stderr"]
|
||||
stored = json.loads((phase_dir / "step0-output.json").read_text(encoding="utf-8"))
|
||||
@@ -162,12 +191,13 @@ def test_invoke_codex_timeout_writes_structured_failure(tmp_path, monkeypatch):
|
||||
def test_execute_single_step_retries_structured_codex_timeout(tmp_path, monkeypatch):
|
||||
phase_dir = tmp_path / "phases" / "retry-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"project": "FESA Structural Solver",
|
||||
"phase": "retry-phase",
|
||||
"steps": [{"step": 0, "name": "retry", "status": "pending"}],
|
||||
"steps": [{"step": 0, "name": "retry", "status": "pending", "owned_paths": []}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -177,6 +207,15 @@ def test_execute_single_step_retries_structured_codex_timeout(tmp_path, monkeypa
|
||||
attempts = []
|
||||
|
||||
def timeout_run(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
responses = {
|
||||
("symbolic-ref", "--quiet", "HEAD"): "refs/heads/feat-retry-phase",
|
||||
("rev-parse", "--verify", "HEAD"): "a" * 40,
|
||||
("status", "--porcelain=v1", "-z", "--untracked-files=all"): "",
|
||||
}
|
||||
return subprocess.CompletedProcess(
|
||||
command, 0, stdout=responses[tuple(command[1:])], stderr=""
|
||||
)
|
||||
attempts.append(command)
|
||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||
|
||||
@@ -184,9 +223,6 @@ def test_execute_single_step_retries_structured_codex_timeout(tmp_path, monkeypa
|
||||
monkeypatch.setattr(execute.subprocess, "run", timeout_run)
|
||||
|
||||
executor = execute.StepExecutor("retry-phase")
|
||||
executor.MAX_RETRIES = 2
|
||||
monkeypatch.setattr(executor, "_commit_step", lambda *args: None)
|
||||
monkeypatch.setattr(executor, "_update_top_index", lambda *args: None)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
executor._execute_single_step(
|
||||
@@ -197,11 +233,15 @@ def test_execute_single_step_retries_structured_codex_timeout(tmp_path, monkeypa
|
||||
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
|
||||
assert index["steps"][0]["status"] == "error"
|
||||
assert "Codex timed out after 1800 seconds." in index["steps"][0]["error_message"]
|
||||
assert index["steps"][0]["attempt_history"][-1]["reason"] == index["steps"][0]["error_message"]
|
||||
top = json.loads((phase_dir.parent / "index.json").read_text(encoding="utf-8"))
|
||||
assert top["phases"][0]["status"] == "error"
|
||||
|
||||
|
||||
def test_invoke_codex_rejects_unknown_sandbox_override(tmp_path, monkeypatch):
|
||||
phase_dir = tmp_path / "phases" / "sandbox-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -215,13 +255,59 @@ def test_invoke_codex_rejects_unknown_sandbox_override(tmp_path, monkeypatch):
|
||||
(phase_dir / "step0.md").write_text("sandbox override", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(execute, "ROOT", tmp_path)
|
||||
monkeypatch.setenv("FESA_HARNESS_CODEX_SANDBOX", "read-only")
|
||||
monkeypatch.setenv("FEM_HARNESS_CODEX_SANDBOX", "read-only")
|
||||
|
||||
executor = execute.StepExecutor("sandbox-phase")
|
||||
with pytest.raises(execute.CodexEnvironmentError, match="FESA_HARNESS_CODEX_SANDBOX"):
|
||||
with pytest.raises(execute.CodexEnvironmentError, match="FEM_HARNESS_CODEX_SANDBOX"):
|
||||
executor._invoke_codex({"step": 0, "name": "sandbox"}, "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("approved", "isolated", "porcelain", "expected_error"),
|
||||
[
|
||||
(False, True, "", "--approve-danger-full-access"),
|
||||
(True, False, "", "isolated linked worktree"),
|
||||
(True, True, " M unrelated.md\0", "clean worktree"),
|
||||
],
|
||||
)
|
||||
def test_sandbox_elevation_rejects_missing_safety_conditions(
|
||||
tmp_path, monkeypatch, approved, isolated, porcelain, expected_error
|
||||
):
|
||||
phase_dir = tmp_path / "phases" / "sandbox-phase"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
(phase_dir / "step0.md").write_text("sandbox guard", encoding="utf-8")
|
||||
(phase_dir / "index.json").write_text(
|
||||
json.dumps({
|
||||
"project": "FESA Structural Solver",
|
||||
"phase": "sandbox-phase",
|
||||
"steps": [{"step": 0, "name": "sandbox", "status": "pending"}],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def git_probe(command, **kwargs):
|
||||
assert command[0] == "git", "Rejected elevation must not launch Codex"
|
||||
common_dir = tmp_path / ".git"
|
||||
git_dir = common_dir / "worktrees/sandbox" if isolated else common_dir
|
||||
responses = {
|
||||
("rev-parse", "--absolute-git-dir"): str(git_dir),
|
||||
("rev-parse", "--git-common-dir"): str(common_dir),
|
||||
("status", "--porcelain=v1", "-z", "--untracked-files=all"): porcelain,
|
||||
}
|
||||
return subprocess.CompletedProcess(
|
||||
command, 0, stdout=responses[tuple(command[1:])], stderr=""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(execute, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(execute.subprocess, "run", git_probe)
|
||||
monkeypatch.setenv("FEM_HARNESS_CODEX_SANDBOX", "danger-full-access")
|
||||
executor = execute.StepExecutor("sandbox-phase", danger_full_access_approved=approved)
|
||||
|
||||
with pytest.raises(execute.CodexEnvironmentError, match=expected_error):
|
||||
executor._codex_sandbox_mode()
|
||||
|
||||
|
||||
def test_codex_environment_failure_detects_missing_sandbox_helper_on_zero_exit():
|
||||
output = {
|
||||
"exitCode": 0,
|
||||
@@ -279,3 +365,205 @@ def test_configure_utf8_stdio_reconfigures_both_streams(monkeypatch):
|
||||
|
||||
assert stdout.encodings == ["utf-8"]
|
||||
assert stderr.encodings == ["utf-8"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def step_runtime(tmp_path, monkeypatch):
|
||||
"""Run the real Step loop and Git against a disposable repo; replace only Codex."""
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
|
||||
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
monkeypatch.delenv("FEM_HARNESS_CODEX_SANDBOX", raising=False)
|
||||
real_run = subprocess.run
|
||||
|
||||
def git(*args):
|
||||
return real_run(
|
||||
["git", *args], cwd=tmp_path, capture_output=True,
|
||||
text=True, encoding="utf-8", check=True,
|
||||
).stdout
|
||||
|
||||
git("init", "--quiet")
|
||||
git("config", "user.name", "Harness Tests")
|
||||
git("config", "user.email", "harness@example.invalid")
|
||||
git("config", "commit.gpgsign", "false")
|
||||
empty_hooks = tmp_path / ".git" / "empty-hooks"
|
||||
empty_hooks.mkdir()
|
||||
git("config", "core.hooksPath", str(empty_hooks))
|
||||
phase_dir = tmp_path / "phases" / "runtime"
|
||||
phase_dir.mkdir(parents=True)
|
||||
write_top_index(phase_dir)
|
||||
index_file = phase_dir / "index.json"
|
||||
index_file.write_text(json.dumps({
|
||||
"project": "Harness Tests", "phase": "runtime",
|
||||
"steps": [{"step": 0, "name": "runtime", "status": "pending", "owned_paths": []}],
|
||||
}), encoding="utf-8")
|
||||
(phase_dir / "step0.md").write_text("Run the assigned test Step.", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("phases/**/step*-output.json\n", encoding="utf-8")
|
||||
write_implementation_agent_profile(tmp_path)
|
||||
git("add", "--", ".")
|
||||
git("commit", "--quiet", "-m", "test: initialize disposable repo")
|
||||
monkeypatch.setattr(execute, "ROOT", tmp_path)
|
||||
runtime = SimpleNamespace(
|
||||
executor=execute.StepExecutor("runtime"), root=tmp_path,
|
||||
index_file=index_file, git=git, real_run=real_run, responses=[], calls=[],
|
||||
)
|
||||
|
||||
def invoke(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
return real_run(command, **kwargs)
|
||||
assert command[0] == "codex", "Only the simulated Codex process is allowed"
|
||||
runtime.calls.append(kwargs["input"])
|
||||
response = runtime.responses.pop(0)
|
||||
if callable(response):
|
||||
response = response(runtime)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
index = json.loads(index_file.read_text(encoding="utf-8"))
|
||||
current = index["steps"][0]
|
||||
current["status"] = response.get("status", "pending")
|
||||
if current["status"] != "pending":
|
||||
field = {"completed": "summary", "error": "error_message", "blocked": "blocked_reason"}[current["status"]]
|
||||
current[field] = response.get("reason", "reported reason")
|
||||
index_file.write_text(json.dumps(index), encoding="utf-8")
|
||||
return subprocess.CompletedProcess(
|
||||
command, response.get("exit", 0),
|
||||
stdout=response.get("stdout", ""), stderr=response.get("stderr", ""),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(execute.subprocess, "run", invoke)
|
||||
return runtime
|
||||
|
||||
|
||||
def run_step(runtime):
|
||||
index = runtime.executor._read_phase_index()
|
||||
return runtime.executor._execute_single_step(index["steps"][0], "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,expected_exit", [("error", 1), ("blocked", 2)])
|
||||
@pytest.mark.parametrize("process_exit", [0, 1, 124])
|
||||
def test_explicit_failure_preserves_reason_without_retry(step_runtime, status, expected_exit, process_exit):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [
|
||||
{"status": status, "reason": "original failure", "exit": process_exit, "stderr": "process diagnostic"},
|
||||
{"status": "completed"},
|
||||
]
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
run_step(runtime)
|
||||
assert stopped.value.code == expected_exit
|
||||
assert len(runtime.calls) == 1
|
||||
current = runtime.executor._read_phase_index()["steps"][0]
|
||||
assert current["status"] == status
|
||||
assert current["error_message" if status == "error" else "blocked_reason"] == "original failure"
|
||||
assert current["attempt_history"][0]["reported_reason"] == "original failure"
|
||||
assert current["attempt_history"][0]["reason"] == "original failure"
|
||||
assert current["attempt_history"][0]["exit_code"] == process_exit
|
||||
assert current["attempt_history"][0]["diagnostic"] == "process diagnostic"
|
||||
assert runtime.executor._read_top_index()["phases"][0]["status"] == status
|
||||
assert runtime.git("rev-list", "--count", "HEAD").strip() == "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("process_exit", [0, 1])
|
||||
@pytest.mark.parametrize("status", ["pending", "completed", "error"])
|
||||
def test_environment_failure_blocks_independent_of_exit_and_report(step_runtime, process_exit, status):
|
||||
runtime = step_runtime
|
||||
diagnostic = "windows sandbox: orchestrator_helper_launch_failed: helper not found"
|
||||
runtime.responses = [{"status": status, "exit": process_exit, "stderr": diagnostic}]
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
run_step(runtime)
|
||||
assert stopped.value.code == 2
|
||||
assert len(runtime.calls) == 1
|
||||
current = runtime.executor._read_phase_index()["steps"][0]
|
||||
assert current["status"] == "blocked"
|
||||
assert "sandbox helper" in current["blocked_reason"]
|
||||
record = current["attempt_history"][0]
|
||||
assert record["reported_status"] == status
|
||||
assert record["classification"] == "environment"
|
||||
assert record["diagnostic"] == diagnostic
|
||||
assert runtime.executor._read_top_index()["phases"][0]["status"] == "blocked"
|
||||
assert runtime.git("rev-list", "--count", "HEAD").strip() == "1"
|
||||
|
||||
|
||||
def test_missing_cli_is_persisted_as_environment_block(step_runtime):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [FileNotFoundError("codex")]
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
run_step(runtime)
|
||||
assert stopped.value.code == 2
|
||||
current = runtime.executor._read_phase_index()["steps"][0]
|
||||
assert current["status"] == "blocked"
|
||||
assert current["attempt_history"][0]["exit_code"] is None
|
||||
assert runtime.executor._read_top_index()["phases"][0]["status"] == "blocked"
|
||||
assert len(runtime.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,process_exit", [("pending", 0), ("pending", 1), ("pending", 124), ("completed", 1)])
|
||||
def test_unclassified_or_inconsistent_result_never_retries(step_runtime, status, process_exit):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [{"status": status, "exit": process_exit}, {"status": "completed"}]
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
run_step(runtime)
|
||||
assert stopped.value.code == 1
|
||||
assert len(runtime.calls) == 1
|
||||
assert runtime.executor._read_phase_index()["steps"][0]["status"] == "error"
|
||||
assert runtime.git("rev-list", "--count", "HEAD").strip() == "1"
|
||||
|
||||
|
||||
def test_timeout_retry_preserves_history_and_can_complete(step_runtime):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [subprocess.TimeoutExpired("codex", 1800), {"status": "completed"}]
|
||||
assert run_step(runtime)
|
||||
current = runtime.executor._read_phase_index()["steps"][0]
|
||||
assert current["status"] == "completed"
|
||||
assert [record["classification"] for record in current["attempt_history"]] == ["transport-timeout", "completed"]
|
||||
assert [record["attempt"] for record in current["attempt_history"]] == [1, 2]
|
||||
assert "timed out" in runtime.calls[1]
|
||||
assert runtime.git("status", "--porcelain").strip() == ""
|
||||
|
||||
|
||||
def test_history_survives_assigned_pending_reset(step_runtime):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [{"status": "error", "reason": "original failure"}]
|
||||
with pytest.raises(SystemExit):
|
||||
run_step(runtime)
|
||||
index = runtime.executor._read_phase_index()
|
||||
history = index["steps"][0]["attempt_history"]
|
||||
index["steps"][0]["status"] = "pending"
|
||||
runtime.executor._write_json(runtime.index_file, index)
|
||||
runtime.executor._ensure_created_at()
|
||||
current = runtime.executor._read_phase_index()["steps"][0]
|
||||
assert "error_message" not in current
|
||||
assert current["attempt_history"] == history
|
||||
runtime.responses = [{"status": "completed"}]
|
||||
assert run_step(runtime)
|
||||
assert [record["attempt"] for record in runtime.executor._read_phase_index()["steps"][0]["attempt_history"]] == [1, 2]
|
||||
|
||||
|
||||
def test_full_runner_completes_metadata_only_phase(step_runtime):
|
||||
runtime = step_runtime
|
||||
runtime.responses = [{"status": "completed"}]
|
||||
runtime.executor.run()
|
||||
assert len(runtime.calls) == 1
|
||||
assert runtime.executor._read_top_index()["phases"][0]["status"] == "completed"
|
||||
assert "completed_at" in runtime.executor._read_phase_index()
|
||||
assert runtime.git("symbolic-ref", "HEAD").strip() == "refs/heads/feat-runtime"
|
||||
assert runtime.git("status", "--porcelain").strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["error", "blocked", "completed"])
|
||||
def test_terminal_report_prevents_retry_even_when_wrapper_times_out(step_runtime, status):
|
||||
runtime = step_runtime
|
||||
|
||||
def report_then_timeout(runtime):
|
||||
index = runtime.executor._read_phase_index()
|
||||
current = index["steps"][0]
|
||||
current["status"] = status
|
||||
field = {"error": "error_message", "blocked": "blocked_reason", "completed": "summary"}[status]
|
||||
current[field] = "original terminal report"
|
||||
runtime.executor._write_json(runtime.index_file, index)
|
||||
return subprocess.TimeoutExpired("codex", 1800)
|
||||
|
||||
runtime.responses = [report_then_timeout]
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
run_step(runtime)
|
||||
assert stopped.value.code == (2 if status == "blocked" else 1)
|
||||
assert len(runtime.calls) == 1
|
||||
assert runtime.executor._read_phase_index()["steps"][0]["attempt_history"][0]["reported_reason"] == "original terminal report"
|
||||
|
||||
Reference in New Issue
Block a user