caller와 모델 조합을 반복 비교할 때 실행·격리·재개 근거가 흔들리지 않도록 manifest, workspace, lifecycle, append-only attempt 기반과 project-local 진입점을 함께 고정한다.
772 lines
32 KiB
Python
772 lines
32 KiB
Python
"""Deterministic subprocess coverage for the bounded benchmark lifecycle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import select
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
from scripts.agent_benchmark.lifecycle import (
|
|
COMPLETION_EXIT_AFTER_IDLE,
|
|
COMPLETION_STOP_AFTER_IDLE,
|
|
REASON_CANCELLED,
|
|
REASON_CLEANUP_FAILED,
|
|
REASON_CONTROLLER_LOST,
|
|
REASON_DUPLICATE_EVENT,
|
|
REASON_MALFORMED_EVENT,
|
|
REASON_MISSING_IDLE,
|
|
REASON_NONZERO_EXIT,
|
|
REASON_OUT_OF_ORDER_EVENT,
|
|
REASON_READER_ERROR,
|
|
REASON_RECOVERED_STOP,
|
|
REASON_START_CALLBACK_FAILED,
|
|
REASON_TIMED_OUT,
|
|
SUBMISSION_ARGV_TASK,
|
|
SUBMISSION_STDIN_ONCE,
|
|
CancellationToken,
|
|
InvocationSpec,
|
|
LifecycleError,
|
|
LifecycleRecoveryError,
|
|
LifecycleValidationError,
|
|
SupervisorLocator,
|
|
env_pairs,
|
|
exact_value_redactor,
|
|
read_locator,
|
|
recover_invocation,
|
|
run_invocation,
|
|
)
|
|
from scripts.agent_benchmark.manifest import Timeout
|
|
|
|
|
|
def _events(_: str, line: str) -> str | None:
|
|
return {"FINISH": "finish", "IDLE": "idle"}.get(line)
|
|
|
|
|
|
class LifecycleTest(unittest.TestCase):
|
|
"""Each test owns a temporary evidence directory and real process group."""
|
|
|
|
def setUp(self) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
|
|
def tearDown(self) -> None:
|
|
self.tmp.cleanup()
|
|
|
|
def _spec(
|
|
self,
|
|
source: str,
|
|
*,
|
|
submission_mode: str = SUBMISSION_ARGV_TASK,
|
|
completion_mode: str = COMPLETION_EXIT_AFTER_IDLE,
|
|
payload: bytes = b"",
|
|
run_seconds: int = 5,
|
|
max_capture_bytes: int = 4096,
|
|
max_capture_lines: int = 100,
|
|
fault_injection: str = "",
|
|
) -> InvocationSpec:
|
|
return InvocationSpec(
|
|
argv=(sys.executable, "-u", "-c", source),
|
|
cwd=str(self.root),
|
|
env=env_pairs({"PATH": os.environ.get("PATH", "/usr/bin:/bin")}),
|
|
submission_mode=submission_mode,
|
|
completion_mode=completion_mode,
|
|
timeout=Timeout(run_seconds, 2, 1, 1),
|
|
evidence_dir=str(self.root),
|
|
task_payload=payload,
|
|
max_capture_bytes=max_capture_bytes,
|
|
max_capture_lines=max_capture_lines,
|
|
fault_injection=fault_injection,
|
|
)
|
|
|
|
def _run(self, spec: InvocationSpec, **kwargs: object):
|
|
return run_invocation(spec, parse_event=_events, on_started=lambda _: None, **kwargs)
|
|
|
|
def _start_supervisor(self, control_dir: Path):
|
|
control_dir.mkdir()
|
|
controller_read, supervisor_write = os.pipe()
|
|
supervisor_read, controller_write = os.pipe()
|
|
process = subprocess.Popen(
|
|
(
|
|
sys.executable,
|
|
"-m",
|
|
"scripts.agent_benchmark.lifecycle",
|
|
f"--read-fd={supervisor_read}",
|
|
f"--write-fd={supervisor_write}",
|
|
f"--control-dir={control_dir}",
|
|
),
|
|
cwd=str(Path(__file__).resolve().parents[2]),
|
|
env={
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"PYTHONPATH": str(Path(__file__).resolve().parents[2]),
|
|
},
|
|
pass_fds=(supervisor_read, supervisor_write),
|
|
start_new_session=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
os.close(supervisor_read)
|
|
os.close(supervisor_write)
|
|
return (
|
|
process,
|
|
os.fdopen(controller_write, "wb", buffering=0),
|
|
os.fdopen(controller_read, "rb", buffering=0),
|
|
)
|
|
|
|
def _send_supervisor_spec(self, writer: object, source: str) -> None:
|
|
self._write_frame(writer, {
|
|
"op": "spec",
|
|
"argv": [sys.executable, "-u", "-c", source],
|
|
"cwd": str(self.root),
|
|
"env": [["PATH", os.environ.get("PATH", "/usr/bin:/bin")]],
|
|
"submission_mode": SUBMISSION_ARGV_TASK,
|
|
"task_payload_hex": "",
|
|
"max_capture_bytes": 1024,
|
|
"cleanup_grace_seconds": 1,
|
|
"fault_injection": "",
|
|
})
|
|
|
|
def _close_supervisor(
|
|
self, process: subprocess.Popen, writer: object, reader: object
|
|
) -> None:
|
|
if process.poll() is None:
|
|
for candidate in self.root.glob("*-control/locator.json"):
|
|
if candidate.is_file():
|
|
try:
|
|
recover_invocation(read_locator(candidate.parent))
|
|
except LifecycleRecoveryError:
|
|
pass
|
|
if process.poll() is not None:
|
|
break
|
|
for handle in (writer, reader):
|
|
try:
|
|
handle.close() # type: ignore[attr-defined]
|
|
except OSError:
|
|
pass
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
def test_exit_after_idle_publishes_ordered_atomic_evidence(self) -> None:
|
|
result = self._run(self._spec("print('FINISH'); print('IDLE')"))
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertTrue(result.cleanup_complete)
|
|
self.assertFalse(result.process_group_alive)
|
|
self.assertTrue(result.finish_then_idle_then_quiet)
|
|
self.assertEqual([event.kind for event in result.events], [
|
|
"submitted", "finish", "idle", "exited", "quiet",
|
|
])
|
|
self.assertTrue(Path(result.journal_path).is_file())
|
|
published = json.loads(Path(result.result_path).read_text(encoding="utf-8"))
|
|
self.assertNotIn("argv", published)
|
|
self.assertNotIn("env", published)
|
|
|
|
def test_stdin_once_submits_exactly_once_and_closes_input(self) -> None:
|
|
source = "import sys; print(sys.stdin.read()); print('FINISH'); print('IDLE')"
|
|
result = self._run(self._spec(
|
|
source,
|
|
submission_mode=SUBMISSION_STDIN_ONCE,
|
|
payload=b"single task payload",
|
|
))
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertIn("single task payload", result.stdout.text)
|
|
self.assertEqual(sum(event.kind == "submitted" for event in result.events), 1)
|
|
|
|
def test_stdin_once_non_reader_times_out_and_cleans_group(self) -> None:
|
|
started = time.monotonic()
|
|
result = self._run(self._spec(
|
|
"import time; time.sleep(30)",
|
|
submission_mode=SUBMISSION_STDIN_ONCE,
|
|
payload=b"x" * (1 << 20),
|
|
run_seconds=1,
|
|
))
|
|
|
|
self.assertLess(time.monotonic() - started, 6)
|
|
self.assertEqual(result.terminal_reason, REASON_TIMED_OUT)
|
|
self.assertFalse(result.submitted)
|
|
self.assertEqual(sum(event.kind == "submitted" for event in result.events), 0)
|
|
self.assertTrue(result.cleanup_complete)
|
|
self.assertFalse(result.process_group_alive)
|
|
|
|
def test_unterminated_final_idle_is_consumed_before_terminal(self) -> None:
|
|
source = "import sys; sys.stdout.write('FINISH\\nIDLE'); sys.stdout.flush()"
|
|
for index, mode in enumerate(
|
|
(COMPLETION_EXIT_AFTER_IDLE, COMPLETION_STOP_AFTER_IDLE)
|
|
):
|
|
with self.subTest(completion_mode=mode):
|
|
evidence = self.root / f"unterminated-{index}"
|
|
evidence.mkdir()
|
|
result = self._run(replace(
|
|
self._spec(source, completion_mode=mode),
|
|
evidence_dir=str(evidence),
|
|
))
|
|
|
|
self.assertTrue(result.success)
|
|
kinds = [event.kind for event in result.events]
|
|
self.assertLess(kinds.index("finish"), kinds.index("idle"))
|
|
self.assertLess(kinds.index("idle"), kinds.index("quiet"))
|
|
records = [
|
|
json.loads(line)
|
|
for line in Path(result.journal_path).read_text(encoding="utf-8").splitlines()
|
|
]
|
|
self.assertEqual(sum(record.get("record") == "terminal" for record in records), 1)
|
|
self.assertEqual(records[-1]["record"], "terminal")
|
|
|
|
def test_stop_after_idle_gracefully_stops_live_caller(self) -> None:
|
|
started = time.monotonic()
|
|
result = self._run(self._spec(
|
|
"import time; print('FINISH'); print('IDLE'); time.sleep(30)",
|
|
completion_mode=COMPLETION_STOP_AFTER_IDLE,
|
|
))
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertLess(time.monotonic() - started, 8)
|
|
self.assertTrue(result.cleanup_complete)
|
|
self.assertFalse(result.process_group_alive)
|
|
|
|
def test_caller_output_cannot_synthesize_submission(self) -> None:
|
|
result = self._run(self._spec("print('submitted'); print('FINISH'); print('IDLE')"))
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertEqual(sum(event.kind == "submitted" for event in result.events), 1)
|
|
self.assertEqual(result.events[0].source, "harness")
|
|
|
|
def test_invalid_terminal_sequences_fail_closed(self) -> None:
|
|
cases = (
|
|
("print('IDLE')", REASON_OUT_OF_ORDER_EVENT),
|
|
("print('FINISH'); print('FINISH')", REASON_DUPLICATE_EVENT),
|
|
("print('FINISH')", REASON_MISSING_IDLE),
|
|
)
|
|
for source, reason in cases:
|
|
with self.subTest(reason=reason):
|
|
evidence = self.root / reason
|
|
evidence.mkdir()
|
|
spec = replace(self._spec(source), evidence_dir=str(evidence))
|
|
result = self._run(spec)
|
|
self.assertFalse(result.success)
|
|
self.assertEqual(result.terminal_reason, reason)
|
|
self.assertTrue(result.cleanup_complete)
|
|
|
|
def test_malformed_parser_and_nonzero_exit_fail_closed(self) -> None:
|
|
malformed = run_invocation(
|
|
self._spec("print('UNKNOWN')"),
|
|
parse_event=lambda _stream, _line: "submitted",
|
|
on_started=lambda _: None,
|
|
)
|
|
self.assertEqual(malformed.terminal_reason, REASON_MALFORMED_EVENT)
|
|
self.assertTrue(malformed.cleanup_complete)
|
|
|
|
evidence = self.root / "nonzero"
|
|
evidence.mkdir()
|
|
failed = self._run(replace(
|
|
self._spec("print('FINISH'); print('IDLE'); raise SystemExit(7)"),
|
|
evidence_dir=str(evidence),
|
|
))
|
|
self.assertEqual(failed.terminal_reason, REASON_NONZERO_EXIT)
|
|
self.assertTrue(failed.cleanup_complete)
|
|
|
|
def test_timeout_cancel_and_reader_error_all_cleanup(self) -> None:
|
|
timeout = self._run(self._spec("import time; time.sleep(30)", run_seconds=1))
|
|
self.assertEqual(timeout.terminal_reason, REASON_TIMED_OUT)
|
|
self.assertTrue(timeout.cleanup_complete)
|
|
|
|
token = CancellationToken()
|
|
timer = threading.Timer(0.2, token.cancel)
|
|
timer.start()
|
|
try:
|
|
evidence = self.root / "cancel"
|
|
evidence.mkdir()
|
|
cancelled = self._run(replace(
|
|
self._spec("import time; time.sleep(30)"), evidence_dir=str(evidence)),
|
|
cancellation=token,
|
|
)
|
|
finally:
|
|
timer.cancel()
|
|
self.assertEqual(cancelled.terminal_reason, REASON_CANCELLED)
|
|
self.assertTrue(cancelled.cleanup_complete)
|
|
|
|
evidence = self.root / "reader"
|
|
evidence.mkdir()
|
|
reader_error = self._run(replace(
|
|
self._spec("print('FINISH'); print('IDLE')", fault_injection="reader_error"),
|
|
evidence_dir=str(evidence),
|
|
))
|
|
self.assertEqual(reader_error.terminal_reason, REASON_READER_ERROR)
|
|
self.assertTrue(reader_error.cleanup_complete)
|
|
|
|
def test_redaction_and_capture_bounds_apply_before_publication(self) -> None:
|
|
secret = "EXACT_SECRET_123456789"
|
|
source = (
|
|
f"print('{secret}'); print('Authorization Bearer fallback-token-123456789'); "
|
|
"print('FINISH'); print('IDLE')"
|
|
)
|
|
result = self._run(
|
|
self._spec(source, max_capture_bytes=4096, max_capture_lines=3),
|
|
redact=exact_value_redactor((secret,)),
|
|
)
|
|
|
|
evidence = Path(result.result_path).read_text(encoding="utf-8")
|
|
self.assertNotIn(secret, evidence)
|
|
self.assertNotIn("fallback-token-123456789", evidence)
|
|
self.assertIn("[redacted]", evidence)
|
|
self.assertTrue(result.stdout.truncated)
|
|
|
|
def test_concurrent_evidence_collision_preserves_existing_files(self) -> None:
|
|
occupied_control = self.root / "occupied-control"
|
|
occupied_control.mkdir()
|
|
locator_sentinel = occupied_control / "locator.json"
|
|
locator_sentinel.write_bytes(b"locator-sentinel")
|
|
with self.assertRaises(LifecycleValidationError):
|
|
self._run(replace(
|
|
self._spec("print('FINISH'); print('IDLE')"),
|
|
control_dir=str(occupied_control),
|
|
))
|
|
self.assertEqual(locator_sentinel.read_bytes(), b"locator-sentinel")
|
|
|
|
racing_control = self.root / "racing-locator-control"
|
|
process, writer, reader = self._start_supervisor(racing_control)
|
|
racing_locator = racing_control / "locator.json"
|
|
racing_locator.write_bytes(b"concurrent-locator")
|
|
try:
|
|
self._send_supervisor_spec(writer, "print('FINISH'); print('IDLE')")
|
|
frames = []
|
|
while True:
|
|
frame = self._read_frame(reader, 5)
|
|
frames.append(frame)
|
|
if frame.get("op") == "terminal":
|
|
break
|
|
self.assertEqual(process.wait(timeout=8), 1)
|
|
self.assertTrue(any(frame.get("op") == "error" for frame in frames))
|
|
finally:
|
|
self._close_supervisor(process, writer, reader)
|
|
self.assertEqual(racing_locator.read_bytes(), b"concurrent-locator")
|
|
|
|
for index, collision_name in enumerate(
|
|
("lifecycle-journal.jsonl", "lifecycle-result.json")
|
|
):
|
|
with self.subTest(collision=collision_name):
|
|
evidence = self.root / f"collision-{index}"
|
|
evidence.mkdir()
|
|
control = self.root / f"collision-control-{index}"
|
|
sentinel = evidence / collision_name
|
|
unrelated = evidence / "unrelated.txt"
|
|
sentinel_bytes = f"sentinel-{index}".encode()
|
|
unrelated.write_bytes(b"unrelated")
|
|
|
|
def collide(_: SupervisorLocator) -> None:
|
|
sentinel.write_bytes(sentinel_bytes)
|
|
|
|
with self.assertRaises(LifecycleError):
|
|
run_invocation(
|
|
replace(
|
|
self._spec("print('FINISH'); print('IDLE')"),
|
|
evidence_dir=str(evidence),
|
|
control_dir=str(control),
|
|
),
|
|
parse_event=_events,
|
|
on_started=collide,
|
|
)
|
|
|
|
self.assertEqual(sentinel.read_bytes(), sentinel_bytes)
|
|
self.assertEqual(unrelated.read_bytes(), b"unrelated")
|
|
other_name = (
|
|
"lifecycle-result.json"
|
|
if collision_name.endswith("jsonl")
|
|
else "lifecycle-journal.jsonl"
|
|
)
|
|
self.assertFalse((evidence / other_name).exists())
|
|
|
|
receipt_evidence = self.root / "receipt-collision-evidence"
|
|
receipt_evidence.mkdir()
|
|
receipt_control = self.root / "receipt-collision-control"
|
|
receipt_sentinel = b"receipt-sentinel"
|
|
|
|
def collide_receipt(locator: SupervisorLocator) -> None:
|
|
(Path(locator.control_dir) / "cleanup-receipt.json").write_bytes(
|
|
receipt_sentinel
|
|
)
|
|
|
|
receipt_result = run_invocation(
|
|
replace(
|
|
self._spec("print('FINISH'); print('IDLE')"),
|
|
evidence_dir=str(receipt_evidence),
|
|
control_dir=str(receipt_control),
|
|
),
|
|
parse_event=_events,
|
|
on_started=collide_receipt,
|
|
)
|
|
self.assertEqual(receipt_result.terminal_reason, REASON_CLEANUP_FAILED)
|
|
self.assertFalse(receipt_result.success)
|
|
self.assertFalse(receipt_result.cleanup_complete)
|
|
self.assertFalse(receipt_result.process_group_alive)
|
|
self.assertEqual(
|
|
(receipt_control / "cleanup-receipt.json").read_bytes(), receipt_sentinel
|
|
)
|
|
published = json.loads(Path(receipt_result.result_path).read_text(encoding="utf-8"))
|
|
self.assertEqual(published["terminal_reason"], REASON_CLEANUP_FAILED)
|
|
journal = [
|
|
json.loads(line)
|
|
for line in Path(receipt_result.journal_path).read_text(encoding="utf-8").splitlines()
|
|
]
|
|
terminals = [record for record in journal if record.get("record") == "terminal"]
|
|
self.assertEqual(len(terminals), 1)
|
|
self.assertEqual(terminals[0]["terminal_reason"], REASON_CLEANUP_FAILED)
|
|
|
|
def test_metric_kind_cannot_leak_secret(self) -> None:
|
|
cases = (
|
|
("exact_secret_123456789", exact_value_redactor(("exact_secret_123456789",))),
|
|
("iop_abcdefghijklmnop", None),
|
|
("a" * 65, None),
|
|
)
|
|
for index, (metric_name, redactor) in enumerate(cases):
|
|
with self.subTest(metric=metric_name[:24]):
|
|
evidence = self.root / f"metric-invalid-{index}"
|
|
evidence.mkdir()
|
|
|
|
def parse_metric(_stream: str, line: str) -> str | None:
|
|
terminal = _events(_stream, line)
|
|
return terminal if terminal is not None else f"metric:{line}"
|
|
|
|
result = run_invocation(
|
|
replace(
|
|
self._spec(
|
|
f"print({metric_name!r}); print('FINISH'); print('IDLE')"
|
|
),
|
|
evidence_dir=str(evidence),
|
|
),
|
|
parse_event=parse_metric,
|
|
on_started=lambda _: None,
|
|
redact=redactor,
|
|
)
|
|
self.assertEqual(result.terminal_reason, REASON_MALFORMED_EVENT)
|
|
self.assertFalse(any(event.kind == f"metric:{metric_name}" for event in result.events))
|
|
persisted = "\n".join(
|
|
path.read_text(encoding="utf-8")
|
|
for path in (Path(result.journal_path), Path(result.result_path))
|
|
)
|
|
if index < 2:
|
|
self.assertNotIn(metric_name, persisted)
|
|
|
|
evidence = self.root / "metric-valid"
|
|
evidence.mkdir()
|
|
|
|
def parse_valid(_stream: str, line: str) -> str | None:
|
|
terminal = _events(_stream, line)
|
|
return terminal if terminal is not None else f"metric:{line}"
|
|
|
|
valid = run_invocation(
|
|
replace(
|
|
self._spec("print('duration_ms'); print('FINISH'); print('IDLE')"),
|
|
evidence_dir=str(evidence),
|
|
),
|
|
parse_event=parse_valid,
|
|
on_started=lambda _: None,
|
|
)
|
|
self.assertTrue(valid.success)
|
|
self.assertIn("metric:duration_ms", [event.kind for event in valid.events])
|
|
|
|
def test_callback_failure_launches_no_caller_and_persists_failure(self) -> None:
|
|
marker = self.root / "caller-ran"
|
|
spec = self._spec(f"from pathlib import Path; Path({str(marker)!r}).write_text('ran')")
|
|
|
|
result = run_invocation(
|
|
spec,
|
|
parse_event=_events,
|
|
on_started=lambda _locator: (_ for _ in ()).throw(RuntimeError("durable write failed")),
|
|
)
|
|
|
|
self.assertEqual(result.terminal_reason, REASON_START_CALLBACK_FAILED)
|
|
self.assertFalse(result.submitted)
|
|
self.assertFalse(marker.exists())
|
|
self.assertTrue(result.cleanup_complete)
|
|
|
|
def test_forged_live_locator_refuses_recovery(self) -> None:
|
|
checked: list[SupervisorLocator] = []
|
|
|
|
def verify(locator: SupervisorLocator) -> None:
|
|
self.assertEqual(read_locator(locator.control_dir), locator)
|
|
checked.append(locator)
|
|
with self.assertRaises(LifecycleRecoveryError):
|
|
recover_invocation(replace(locator, challenge="forged-marker"))
|
|
|
|
result = run_invocation(
|
|
self._spec("print('FINISH'); print('IDLE')"),
|
|
parse_event=_events,
|
|
on_started=verify,
|
|
)
|
|
self.assertTrue(checked)
|
|
self.assertTrue(result.success)
|
|
|
|
def test_owned_descendant_ignoring_term_is_killed_and_reaped(self) -> None:
|
|
descendant_pid_path = self.root / "descendant.pid"
|
|
descendant_source = (
|
|
"import signal,time; from pathlib import Path; "
|
|
"signal.signal(signal.SIGTERM, signal.SIG_IGN); "
|
|
f"Path({str(descendant_pid_path)!r}).write_text(str(__import__('os').getpid())); "
|
|
"time.sleep(30)"
|
|
)
|
|
caller_source = (
|
|
"import subprocess,sys,time; from pathlib import Path; "
|
|
f"p=subprocess.Popen([sys.executable,'-c',{descendant_source!r}]); "
|
|
f"deadline=time.monotonic()+5; marker=Path({str(descendant_pid_path)!r}); "
|
|
"\nwhile not marker.exists() and time.monotonic()<deadline: time.sleep(.01)\n"
|
|
"print('FINISH'); print('IDLE'); sys.stdout.flush(); time.sleep(30)"
|
|
)
|
|
evidence = self.root / "descendant-evidence"
|
|
evidence.mkdir()
|
|
control = self.root / "descendant-control"
|
|
descendant_pid = 0
|
|
try:
|
|
result = self._run(replace(
|
|
self._spec(
|
|
caller_source,
|
|
completion_mode=COMPLETION_STOP_AFTER_IDLE,
|
|
),
|
|
evidence_dir=str(evidence),
|
|
control_dir=str(control),
|
|
))
|
|
|
|
descendant_pid = int(descendant_pid_path.read_text(encoding="utf-8"))
|
|
proc_path = Path(f"/proc/{descendant_pid}")
|
|
deadline = time.monotonic() + 2
|
|
while proc_path.exists() and time.monotonic() < deadline:
|
|
time.sleep(.02)
|
|
self.assertTrue(result.success)
|
|
self.assertTrue(result.cleanup_complete)
|
|
self.assertFalse(result.process_group_alive)
|
|
self.assertFalse(proc_path.exists())
|
|
receipt = json.loads(
|
|
(control / "cleanup-receipt.json").read_text(encoding="utf-8")
|
|
)
|
|
self.assertTrue(receipt["cleanup_complete"])
|
|
self.assertFalse(receipt["process_group_alive"])
|
|
finally:
|
|
if not descendant_pid and descendant_pid_path.is_file():
|
|
descendant_pid = int(descendant_pid_path.read_text(encoding="utf-8"))
|
|
if descendant_pid and Path(f"/proc/{descendant_pid}").exists():
|
|
try:
|
|
os.kill(descendant_pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
def test_authenticated_recovery_status_and_stop(self) -> None:
|
|
control = self.root / "recovery-control"
|
|
process, writer, reader = self._start_supervisor(control)
|
|
try:
|
|
self._send_supervisor_spec(writer, "import time; time.sleep(30)")
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "registered")
|
|
locator = read_locator(control)
|
|
self._write_frame(writer, {"op": "start"})
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "started")
|
|
|
|
status = recover_invocation(locator, stop=False)
|
|
self.assertTrue(status.caller_launched)
|
|
self.assertTrue(status.process_group_alive)
|
|
stopped = recover_invocation(locator, stop=True)
|
|
self.assertEqual(stopped.reason, REASON_RECOVERED_STOP)
|
|
self.assertTrue(stopped.cleanup_complete)
|
|
self.assertFalse(stopped.process_group_alive)
|
|
self.assertEqual(process.wait(timeout=8), 0)
|
|
finally:
|
|
self._close_supervisor(process, writer, reader)
|
|
|
|
receipt = json.loads((control / "cleanup-receipt.json").read_text(encoding="utf-8"))
|
|
self.assertEqual(receipt["reason"], REASON_RECOVERED_STOP)
|
|
self.assertTrue(receipt["cleanup_complete"])
|
|
|
|
def test_locator_identity_mismatches_refuse_recovery(self) -> None:
|
|
control = self.root / "mismatch-control"
|
|
process, writer, reader = self._start_supervisor(control)
|
|
independent = subprocess.Popen(
|
|
(sys.executable, "-c", "import time; time.sleep(30)"),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
try:
|
|
self._send_supervisor_spec(writer, "import time; time.sleep(30)")
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "registered")
|
|
locator = read_locator(control)
|
|
self._write_frame(writer, {"op": "start"})
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "started")
|
|
|
|
with self.assertRaises(LifecycleRecoveryError):
|
|
recover_invocation(replace(locator, start_identity="mismatched-start"))
|
|
with self.assertRaises(LifecycleRecoveryError):
|
|
recover_invocation(replace(locator, supervisor_pid=independent.pid))
|
|
status = recover_invocation(locator, stop=False)
|
|
self.assertTrue(status.process_group_alive)
|
|
stopped = recover_invocation(locator)
|
|
self.assertTrue(stopped.cleanup_complete)
|
|
self.assertFalse(stopped.process_group_alive)
|
|
self.assertEqual(process.wait(timeout=8), 0)
|
|
finally:
|
|
independent.terminate()
|
|
independent.wait(timeout=5)
|
|
self._close_supervisor(process, writer, reader)
|
|
|
|
def test_controller_eof_before_start_launches_no_caller(self) -> None:
|
|
control = self.root / "pre-start-control"
|
|
marker = self.root / "pre-start-caller-ran"
|
|
process, writer, reader = self._start_supervisor(control)
|
|
try:
|
|
self._send_supervisor_spec(
|
|
writer,
|
|
f"from pathlib import Path; Path({str(marker)!r}).write_text('ran')",
|
|
)
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "registered")
|
|
writer.close()
|
|
self.assertEqual(process.wait(timeout=8), 0)
|
|
finally:
|
|
self._close_supervisor(process, writer, reader)
|
|
|
|
receipt = json.loads((control / "cleanup-receipt.json").read_text(encoding="utf-8"))
|
|
self.assertEqual(receipt["reason"], REASON_CONTROLLER_LOST)
|
|
self.assertFalse(receipt["caller_launched"])
|
|
self.assertTrue(receipt["cleanup_complete"])
|
|
self.assertFalse(marker.exists())
|
|
|
|
def test_near_deadline_terminal_reason_and_receipt_are_consistent(self) -> None:
|
|
cases = (
|
|
(
|
|
"timeout",
|
|
"import time; time.sleep(.85); print('FINISH'); print('IDLE')",
|
|
REASON_TIMED_OUT,
|
|
None,
|
|
),
|
|
(
|
|
"cancel",
|
|
"import time; time.sleep(.15); print('FINISH'); print('IDLE'); time.sleep(30)",
|
|
REASON_CANCELLED,
|
|
.9,
|
|
),
|
|
)
|
|
for index, (name, source, expected, cancel_after) in enumerate(cases):
|
|
with self.subTest(race=name):
|
|
evidence = self.root / f"race-evidence-{index}"
|
|
evidence.mkdir()
|
|
control = self.root / f"race-control-{index}"
|
|
token = CancellationToken() if cancel_after is not None else None
|
|
timer = (
|
|
threading.Timer(cancel_after, token.cancel)
|
|
if cancel_after is not None and token is not None
|
|
else None
|
|
)
|
|
if timer is not None:
|
|
timer.start()
|
|
try:
|
|
result = self._run(
|
|
replace(
|
|
self._spec(
|
|
source,
|
|
completion_mode=COMPLETION_STOP_AFTER_IDLE,
|
|
run_seconds=1 if name == "timeout" else 5,
|
|
),
|
|
evidence_dir=str(evidence),
|
|
control_dir=str(control),
|
|
),
|
|
cancellation=token,
|
|
)
|
|
finally:
|
|
if timer is not None:
|
|
timer.cancel()
|
|
|
|
receipt = json.loads(
|
|
(control / "cleanup-receipt.json").read_text(encoding="utf-8")
|
|
)
|
|
published = json.loads(Path(result.result_path).read_text(encoding="utf-8"))
|
|
journal = [
|
|
json.loads(line)
|
|
for line in Path(result.journal_path).read_text(encoding="utf-8").splitlines()
|
|
]
|
|
terminals = [record for record in journal if record.get("record") == "terminal"]
|
|
self.assertEqual(result.terminal_reason, expected)
|
|
self.assertEqual(receipt["reason"], expected)
|
|
self.assertEqual(published["terminal_reason"], expected)
|
|
self.assertEqual(len(terminals), 1)
|
|
self.assertEqual(terminals[0]["terminal_reason"], expected)
|
|
self.assertEqual(journal[-1]["record"], "terminal")
|
|
self.assertTrue(result.cleanup_complete)
|
|
self.assertFalse(result.process_group_alive)
|
|
|
|
def test_controller_eof_routes_supervisor_through_cleanup(self) -> None:
|
|
"""Exercise the crash window directly: EOF after START must clean the group."""
|
|
control_dir = self.root / "control"
|
|
control_dir.mkdir()
|
|
controller_read, supervisor_write = os.pipe()
|
|
supervisor_read, controller_write = os.pipe()
|
|
process = subprocess.Popen(
|
|
(
|
|
sys.executable,
|
|
"-m",
|
|
"scripts.agent_benchmark.lifecycle",
|
|
f"--read-fd={supervisor_read}",
|
|
f"--write-fd={supervisor_write}",
|
|
f"--control-dir={control_dir}",
|
|
),
|
|
cwd=str(Path(__file__).resolve().parents[2]),
|
|
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "PYTHONPATH": str(Path(__file__).resolve().parents[2])},
|
|
pass_fds=(supervisor_read, supervisor_write),
|
|
start_new_session=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
os.close(supervisor_read)
|
|
os.close(supervisor_write)
|
|
writer = os.fdopen(controller_write, "wb", buffering=0)
|
|
reader = os.fdopen(controller_read, "rb", buffering=0)
|
|
try:
|
|
self._write_frame(writer, {
|
|
"op": "spec",
|
|
"argv": [sys.executable, "-u", "-c", "import time; time.sleep(30)"],
|
|
"cwd": str(self.root),
|
|
"env": [["PATH", os.environ.get("PATH", "/usr/bin:/bin")]],
|
|
"submission_mode": SUBMISSION_ARGV_TASK,
|
|
"task_payload_hex": "",
|
|
"max_capture_bytes": 1024,
|
|
"cleanup_grace_seconds": 1,
|
|
"fault_injection": "",
|
|
})
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "registered")
|
|
self._write_frame(writer, {"op": "start"})
|
|
self.assertEqual(self._read_frame(reader, 5).get("op"), "started")
|
|
writer.close() # Simulated controller loss.
|
|
self.assertEqual(process.wait(timeout=8), 0)
|
|
finally:
|
|
reader.close()
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
receipt = json.loads((control_dir / "cleanup-receipt.json").read_text(encoding="utf-8"))
|
|
self.assertEqual(receipt["reason"], REASON_CONTROLLER_LOST)
|
|
self.assertTrue(receipt["caller_launched"])
|
|
self.assertTrue(receipt["cleanup_complete"])
|
|
self.assertFalse(receipt["process_group_alive"])
|
|
|
|
@staticmethod
|
|
def _write_frame(handle: object, value: dict[str, object]) -> None:
|
|
handle.write((json.dumps(value) + "\n").encode("utf-8")) # type: ignore[attr-defined]
|
|
handle.flush() # type: ignore[attr-defined]
|
|
|
|
@staticmethod
|
|
def _read_frame(handle: object, timeout: float) -> dict[str, object]:
|
|
ready, _, _ = select.select([handle], [], [], timeout)
|
|
if not ready:
|
|
raise AssertionError("timed out waiting for supervisor frame")
|
|
raw = handle.readline() # type: ignore[attr-defined]
|
|
if not raw:
|
|
raise AssertionError("supervisor closed its frame stream")
|
|
return json.loads(raw)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|