Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions src/outerloop/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,21 +1440,3 @@ def run(
prior_turns.append({"role": "assistant", "text": redact(result.final_text, secrets)})
_save_resume_transcript(session_home, session_id, prior_turns)
return replace(result, session_id=session_id)


@dataclass
class FakeHarness:
"""Deterministic in-process harness for tests and dry runs."""

result: SessionResult
script: Any = None # optional callable(brief_text, workspace) for side effects
calls: list[tuple[str, str, str | None]] = field(default_factory=list)
supports_resume: bool = True # a field so tests can exercise the no-resume path

def run(
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
) -> SessionResult:
self.calls.append((brief_text, str(workspace), resume_session_id))
if self.script is not None:
self.script(brief_text, workspace)
return self.result
6 changes: 5 additions & 1 deletion src/outerloop/syscall_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,10 @@ def build_parser() -> argparse.ArgumentParser:
return p


# seconds between checks of the kernel's done marker; tests shrink it
SYNC_POLL_S = 15


def cmd_sync(root: Path, args) -> str:
"""Ask the kernel for fresh origin/* refs and wait, inside this session's
own clock. The kernel acts on its next cycle (cadence up to 30 minutes),
Expand Down Expand Up @@ -505,7 +509,7 @@ def acknowledged() -> bool:
"continuing with current refs (they refresh at your next "
"wake regardless)."
)
time.sleep(15)
time.sleep(SYNC_POLL_S)


def cmd_siblings(root: Path, _args) -> str:
Expand Down
12 changes: 0 additions & 12 deletions src/outerloop/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,6 @@ class WakeDispatcher(Protocol):
def dispatch(self, record: RunRecord, reason: str) -> str: ...


@dataclass
class RecordingDispatcher:
"""Test/dry-run dispatcher: records what would have been woken."""

dispatched: list[tuple[str, str]] = field(default_factory=list)
holder_job_id: str = "" # set to simulate async dispatch

def dispatch(self, record: RunRecord, reason: str) -> str:
self.dispatched.append((record.run_id, reason))
return self.holder_job_id


@dataclass(frozen=True)
class TickReport:
paused: bool = False
Expand Down
40 changes: 40 additions & 0 deletions tests/fakes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""In-process test doubles for the kernel's seams."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from outerloop.harness import SessionResult
from outerloop.runstate import RunRecord


@dataclass
class FakeHarness:
"""Deterministic in-process harness: records calls, returns a canned result."""

result: SessionResult
script: Any = None # optional callable(brief_text, workspace) for side effects
calls: list[tuple[str, str, str | None]] = field(default_factory=list)
supports_resume: bool = True # a field so tests can exercise the no-resume path

def run(
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
) -> SessionResult:
self.calls.append((brief_text, str(workspace), resume_session_id))
if self.script is not None:
self.script(brief_text, workspace)
return self.result


@dataclass
class RecordingDispatcher:
"""Dispatcher that records what would have been woken."""

dispatched: list[tuple[str, str]] = field(default_factory=list)
holder_job_id: str = "" # set to simulate async dispatch

def dispatch(self, record: RunRecord, reason: str) -> str:
self.dispatched.append((record.run_id, reason))
return self.holder_job_id
21 changes: 21 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Small utilities shared across test modules."""

from __future__ import annotations

import time
from collections.abc import Callable


def wait_until(
predicate: Callable[[], object], timeout: float = 5.0, interval: float = 0.01
) -> bool:
"""Poll `predicate` until it is truthy or `timeout` seconds pass; True if it
became so, False on timeout. Callers assert the result, so a timeout fails
the test rather than passing silently."""
deadline = time.monotonic() + timeout
while True:
if predicate():
return True
if time.monotonic() >= deadline:
return False
time.sleep(interval)
17 changes: 8 additions & 9 deletions tests/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@

import pytest

from fakes import FakeHarness
from helpers import wait_until
from outerloop.harness import (
SESSION_ENV_ALLOWLIST,
ClaudeCodeHarness,
CodexHarness,
FakeHarness,
SessionResult,
budget_exhausted,
outage,
Expand Down Expand Up @@ -431,18 +432,16 @@ def test_timeout_kills_the_whole_process_group(tmp_path: Path) -> None:
result = ClaudeCodeHarness(api_key="k", binary=str(script), timeout_s=1).run("task", ws)
assert result.stop_reason == "timeout"
child = int((tmp_path / "child_pid").read_text().strip())
# the grandchild lingers as a zombie until init reaps it — poll briefly
import time

deadline = time.time() + 5
while time.time() < deadline:
# the grandchild lingers as a zombie until init reaps it — poll briefly
def gone() -> bool:
try:
_os.kill(child, 0)
except ProcessLookupError:
break
time.sleep(0.05)
else:
raise AssertionError(f"child {child} survived the group kill")
return True
return False

assert wait_until(gone, interval=0.05), f"child {child} survived the group kill"


def test_transcript_and_home_are_owner_only(tmp_path: Path) -> None:
Expand Down
14 changes: 10 additions & 4 deletions tests/test_local_compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest

from helpers import wait_until
from outerloop.compute import GONE, JobSpec, LocalCompute
from outerloop.dispatch import snapshot_tree
from outerloop.github import Workspace
Expand Down Expand Up @@ -228,16 +229,21 @@ def test_walltime_kill_takes_the_whole_process_group(tmp_path: Path) -> None:
# Slurm kills the job's process group at walltime; a local job script
# waiting on a child must not leave that child running past it
import os
import time

pidfile = tmp_path / "child.pid"
lc = LocalCompute(minute_s=1) # 1-minute walltime == 1 second, for the test
job = lc.submit(_spec(command=f"sleep 300 & echo $! > {pidfile}; wait", minutes=1))
assert lc.status(job) == "TIMEOUT"
child = int(pidfile.read_text().strip())
time.sleep(0.1) # let the SIGKILL land
with pytest.raises(ProcessLookupError):
os.kill(child, 0) # the child died with the group

def gone() -> bool: # died with the group, once the SIGKILL lands
try:
os.kill(child, 0)
except ProcessLookupError:
return True
return False

assert wait_until(gone), f"child {child} survived the group kill"


def test_job_env_is_an_allowlist_not_the_submitter_env(tmp_path: Path) -> None:
Expand Down
3 changes: 2 additions & 1 deletion tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

import pytest

from outerloop.harness import FakeHarness, SessionResult
from fakes import FakeHarness
from outerloop.harness import SessionResult
from outerloop.orchestrator import (
EvalError,
RunConfig,
Expand Down
28 changes: 23 additions & 5 deletions tests/test_sweep_git_locks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import pytest

from helpers import wait_until

SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "sweep_git_locks.sh"
HOUR_AGO = time.time() - 3600

Expand Down Expand Up @@ -41,6 +43,22 @@ def _aged_lock(path: Path) -> Path:
return path


def _live(proc: subprocess.Popen[bytes]) -> bool:
"""Is `proc` alive with its argv readable — what the sweep needs to see it?
Reads the cmdline the way the sweep does (/proc on Linux, `ps` on a Mac),
so the test's readiness never diverges from the sweep's own detection."""
if proc.poll() is not None: # already exited: never "live"
return False
try:
with open(f"/proc/{proc.pid}/cmdline", "rb") as fh:
cmd = fh.read().replace(b"\x00", b" ").decode()
except OSError: # no /proc (macOS): ps, as the sweep also falls back to
cmd = subprocess.run(
["ps", "-o", "args=", "-p", str(proc.pid)], capture_output=True, text=True
).stdout
return "hash-object" in cmd


def _sweep(checkout: Path, min_age: str = "10") -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(SCRIPT), str(checkout), min_age], check=True, capture_output=True, text=True
Expand Down Expand Up @@ -86,7 +104,7 @@ def test_an_aged_lock_under_a_live_git_process_is_left_alone(tmp_path: Path) ->
cwd=tmp_path,
)
try:
time.sleep(0.3)
assert wait_until(lambda: _live(proc))
out = _sweep(repo)
assert lock.exists() and out.stdout == ""
finally:
Expand All @@ -113,7 +131,7 @@ def test_a_linked_worktree_is_followed_to_its_git_dir(tmp_path: Path) -> None:
stdout=subprocess.DEVNULL,
)
try:
time.sleep(0.3)
assert wait_until(lambda: _live(proc))
Comment thread
renmengye marked this conversation as resolved.
assert _sweep(linked).stdout == "" and common_lock.exists()
finally:
assert proc.stdin is not None
Expand Down Expand Up @@ -162,7 +180,7 @@ def test_a_prefix_named_sibling_checkout_never_blocks_the_sweep(tmp_path: Path)
stdout=subprocess.DEVNULL,
)
try:
time.sleep(0.3)
assert wait_until(lambda: _live(proc))
out = _sweep(repo)
assert not lock.exists() and "swept stale git lock" in out.stdout
finally:
Expand All @@ -183,7 +201,7 @@ def test_a_worktree_path_with_spaces_still_counts_as_live(tmp_path: Path) -> Non
cwd=linked,
)
try:
time.sleep(0.3)
assert wait_until(lambda: _live(proc))
assert _sweep(repo).stdout == "" and common_lock.exists()
finally:
assert proc.stdin is not None
Expand All @@ -205,7 +223,7 @@ def test_split_form_git_dir_and_work_tree_from_the_parent_count_as_live(tmp_path
cwd=tmp_path,
)
try:
time.sleep(0.3)
assert wait_until(lambda: _live(proc))
assert _sweep(repo).stdout == "" and lock.exists()
finally:
assert proc.stdin is not None
Expand Down
8 changes: 7 additions & 1 deletion tests/test_syscall.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ def test_parse_elapsed_reads_sacct_fields() -> None:
assert parse_elapsed("x-01:00:00") is None


def test_sync_cli_waits_for_the_done_marker(tmp_path) -> None:
def test_sync_cli_waits_for_the_done_marker(tmp_path, monkeypatch) -> None:
"""The verb touches the request, then blocks until the kernel stamps
done — the wait is the session's own time, never a new leg."""
import threading
Expand All @@ -916,6 +916,7 @@ def test_sync_cli_waits_for_the_done_marker(tmp_path) -> None:
from outerloop.syscall_cli import cmd_sync

(tmp_path / SYSCALL_DIR).mkdir()
monkeypatch.setattr("outerloop.syscall_cli.SYNC_POLL_S", 0.01)
Comment thread
renmengye marked this conversation as resolved.

class A:
minutes = 1
Expand All @@ -925,8 +926,13 @@ def stamp_soon():
mark_synced(tmp_path, _time.time())

threading.Thread(target=stamp_soon, daemon=True).start()
started = _time.monotonic()
out = cmd_sync(tmp_path, A())
elapsed = _time.monotonic() - started
assert "refreshed" in out
# the poll interval was honored: the default 15 s would block far longer
# than the marker's 0.3 s arrival, so this bounds it well under one interval
assert elapsed < 5, elapsed


def test_sync_cli_times_out_gracefully(tmp_path, monkeypatch) -> None:
Expand Down
20 changes: 10 additions & 10 deletions tests/test_syscall_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
from typing import Any

from helpers import wait_until
from outerloop.syscall import read_request, read_verdict
from outerloop.syscall_cli import main

Expand Down Expand Up @@ -459,18 +460,17 @@ def __exit__(self, *exc: object) -> None:
self._thread.join(timeout=10)

def _serve(self) -> None:
import time

from outerloop.syscall import mark_done, marker_requested, write_channel_json

deadline = time.time() + 10
while time.time() < deadline:
at = marker_requested(self.root, f"{self.verb}-request", f"{self.verb}-done")
if at is not None:
write_channel_json(self.root, f"{self.verb}.json", self.payload)
mark_done(self.root, f"{self.verb}-done", at)
return
time.sleep(0.05)
def requested() -> float | None:
return marker_requested(self.root, f"{self.verb}-request", f"{self.verb}-done")

if not wait_until(lambda: requested() is not None, timeout=10, interval=0.05):
return
at = requested()
assert at is not None
write_channel_json(self.root, f"{self.verb}.json", self.payload)
mark_done(self.root, f"{self.verb}-done", at)


_QUEUE: dict[str, Any] = {
Expand Down
6 changes: 3 additions & 3 deletions tests/test_tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from typing import Any

from fakes import RecordingDispatcher
from outerloop.compute import CommandResult, SlurmCompute
from outerloop.runstate import (
ENDED,
Expand All @@ -23,7 +24,6 @@
DEFAULT_MIN_TICK_S,
PAUSE_SENTINEL,
WORK_MARKER_NAME,
RecordingDispatcher,
_mark_worked,
mark_tick_complete,
tick,
Expand Down Expand Up @@ -357,7 +357,7 @@ def test_first_terminal_sighting_starts_the_grace_clock_not_a_wake(tmp_path: Pat
def test_dry_run_reports_without_any_writes(tmp_path: Path) -> None:
"""python -m outerloop.tick runs exactly this until phase 5: a healthy
completed run must survive any number of dry ticks unchanged."""
from outerloop.tick import RecordingDispatcher as RD
from fakes import RecordingDispatcher as RD
from outerloop.tick import tick as tick_fn

waiting_run(tmp_path)
Expand Down Expand Up @@ -3201,7 +3201,7 @@ def test_sweep_gives_a_dependency_pending_wake_the_grace_window(tmp_path: Path)
waiting_run(tmp_path, terminal_seen=0.0)
acquire_lease(tmp_path, "r1", "wake-job:55", "55", now=NOW - 60)
slurm = FakeSlurm(states={"100": "COMPLETED", "55": "PENDING"}, reasons={"55": "Dependency"})
from outerloop.tick import RecordingDispatcher as RD
from fakes import RecordingDispatcher as RD

d = RD()
tick_fn(tmp_path, slurm.compute(), d, now=NOW, min_tick_s=0)
Expand Down
Loading
Loading