Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Each task gets its own directory, its own worker, its own log, and its own verdi
| `timeout_s` | Per-task kill timer (default 900) |
| `max_attempts` | How many times this task may run (default 2 — one try plus one retry with the check's failure output injected). Set `1` for a hard no-retry lane |
| `redact_spec` | Replace this task's spec with `[redacted request packet]` in the run state, the logged command line, and the eval row, for specs carrying sensitive material. Redacts Ringer's own records only — captured worker output is never rewritten (invariant), so a worker that echoes its request still puts that text in `worker.log` |
| `check_timeout_s` | Kill timer for the `check` command (default 60, ceiling 3600) — raise it when the check runs a real build or test suite (`xcodebuild`, `cargo test`, …); lint nudges you when it spots one at the default |
| `engine_args` | Extra CLI flags for this task's worker, spliced in at the engine's `{engine_args}` placeholder — e.g. `["-c", "model_reasoning_effort=low"]` so the orchestrator picks reasoning depth per task |
| `verified` | One plain-English sentence saying what the check proves — shown on the results page next to "finished & checked" |
| `full_access` | Worker runs unsandboxed — required for workers that spawn their own sub-workers; must also be enabled in config |
Expand Down Expand Up @@ -392,6 +393,7 @@ Every community PR that lands in main is credited here — that's a project rule
- [@davekopecek](https://github.com/davekopecek) (Dave Kopecek) — committed the design-reference fixture so the design-token guard runs on every machine (#30)
- [@snapsynapse](https://github.com/snapsynapse) (Sam Rogers) — graceful shutdown on SIGINT/SIGTERM with worker-tree cleanup and finished state, plus the 14-test end-to-end CLI regression suite (#4)
- [@mlava](https://github.com/mlava) (Mark Lavercombe) — named setup failures across every diagnostic surface (#37) and `run --baseline`, the no-workers check preflight (#38)
- [@brandoncordoba](https://github.com/brandoncordoba) (Brandon Cordoba) — per-task `check_timeout_s` so checks that run real builds aren't killed at the 60s default

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the philosophy and what gets a PR merged fast. The short version: small and scoped, rebased on current main, every claim backed by an executed test. Authorship is always preserved — where a maintainer pushes a mechanical fix to your branch, you remain the commit author.

Expand Down
86 changes: 82 additions & 4 deletions ringer.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
DEFAULT_ENGINE_NAME = "codex"
DEFAULT_TIMEOUT_S = 900
CHECK_TIMEOUT_S = 60
# Checks that run real builds or test suites need more than the default, but
# a check is verification, not the work itself — an hour is the ceiling.
CHECK_TIMEOUT_CEILING_S = 3600
DEFAULT_DASHBOARD_PORT_BASE = 8787
DEFAULT_HUD_PORT = 8700
DEFAULT_CATALOG_SOURCE = "https://openrouter.ai/api/v1/models"
Expand Down Expand Up @@ -1635,6 +1638,7 @@ class TaskSpec:
timeout_s: int = DEFAULT_TIMEOUT_S
max_attempts: int = 2
redact_spec: bool = False
check_timeout_s: int = CHECK_TIMEOUT_S
full_access: bool = False
engine_args: tuple[str, ...] = ()
verified: str = ""
Expand Down Expand Up @@ -1682,6 +1686,14 @@ def from_obj(cls, obj: dict[str, Any]) -> "TaskSpec":
max_attempts = raw_max_attempts
if max_attempts <= 0:
raise ValueError(f"task {key}: max_attempts must be positive")
check_timeout_s = int(obj.get("check_timeout_s", CHECK_TIMEOUT_S))
if check_timeout_s <= 0:
raise ValueError(f"task {key}: check_timeout_s must be positive")
if check_timeout_s > CHECK_TIMEOUT_CEILING_S:
raise ValueError(
f"task {key}: check_timeout_s must be <= {CHECK_TIMEOUT_CEILING_S}; "
"a check is verification, not the work — move longer jobs into the task itself"
)
engine_args = obj.get("engine_args", [])
if not isinstance(engine_args, list) or not all(isinstance(item, str) for item in engine_args):
raise ValueError(f"task {key}: engine_args must be a list of strings")
Expand All @@ -1703,6 +1715,7 @@ def from_obj(cls, obj: dict[str, Any]) -> "TaskSpec":
timeout_s=timeout_s,
max_attempts=max_attempts,
redact_spec=require_bool(obj.get("redact_spec", False), key, "redact_spec"),
check_timeout_s=check_timeout_s,
full_access=bool(obj.get("full_access", False)),
engine_args=tuple(engine_args),
verified=verified.strip(),
Expand Down Expand Up @@ -1821,6 +1834,13 @@ def lint_manifest(
findings.append(
f"{task.key}: check may fail without printing why; retry prompt and eval log depend on failure output."
)
slow_command = slow_check_command(task.check)
if slow_command and task.check_timeout_s <= CHECK_TIMEOUT_S:
findings.append(
f"{task.key}: check runs '{slow_command}' but check_timeout_s is not raised; "
f"builds and test suites rarely finish inside the {CHECK_TIMEOUT_S}s default — "
f"set check_timeout_s (up to {CHECK_TIMEOUT_CEILING_S})."
)
if manifest.worktrees and any(is_relative_expect_file(path) for path in task.expect_files):
findings.append(
f"{task.key}: deliverable would be deleted with the worktree; write it outside the worktree or export it in the check."
Expand Down Expand Up @@ -1971,6 +1991,59 @@ def consists_only_of_echo_commands(command: str) -> bool:
return True


# Tools whose runs routinely outlast the default check timeout. A value of
# None means the tool alone is the signal (xcodebuild is never fast); a set
# means only those subcommands are slow (swift repl is fine, swift test isn't).
SLOW_CHECK_TOOLS: dict[str, frozenset[str] | None] = {
"xcodebuild": None,
"swift": frozenset({"build", "test"}),
"cargo": frozenset({"build", "test", "nextest"}),
"go": frozenset({"build", "test"}),
"npm": frozenset({"test", "ci", "build"}),
"yarn": frozenset({"test", "build"}),
"pnpm": frozenset({"test", "build"}),
"pytest": None,
"tox": None,
"gradle": None,
"gradlew": None,
"mvn": None,
"bazel": frozenset({"build", "test"}),
"dotnet": frozenset({"build", "test"}),
"make": None,
}

SHELL_COMMAND_WRAPPERS = {"time", "env", "nice", "exec", "command"}


def slow_check_command(check: str) -> str | None:
"""Return the build/test-shaped command a check runs directly, if any.

Matches only at command position (start of a pipeline segment) so tool
names inside echo strings or script arguments don't fire. A check that
hides the build inside another script is invisible here — this is a
nudge for the common case, not a proof.
"""
for part in command_parts(strip_shell_comments(check)):
for segment in part.split("|"):
tokens = segment.split()
while tokens and (
"=" in tokens[0].split("/", 1)[0] or tokens[0] in SHELL_COMMAND_WRAPPERS or tokens[0] == "!"
):
tokens.pop(0)
if not tokens:
continue
tool = tokens[0].rsplit("/", 1)[-1]
subcommands = SLOW_CHECK_TOOLS.get(tool)
if tool not in SLOW_CHECK_TOOLS:
continue
if subcommands is None:
return tool
for token in tokens[1:]:
if token in subcommands:
return f"{tool} {token}"
return None


def check_may_fail_silently(check: str) -> bool:
stripped = strip_shell_comments(check).strip()
if has_quiet_diff_probe(stripped):
Expand Down Expand Up @@ -8607,7 +8680,9 @@ def run_models_command(config: AppConfig, args: argparse.Namespace) -> int:

class Verifier:
async def verify(self, task: TaskSpec, taskdir: Path) -> VerifyResult:
check_returncode, check_timed_out, output = await self._run_check(task.check, taskdir)
check_returncode, check_timed_out, output = await self._run_check(
task.check, taskdir, timeout_s=task.check_timeout_s
)
missing_files = tuple(
rel for rel in task.expect_files if not self._is_nonempty_file(self._expect_file_path(taskdir, rel))
)
Expand Down Expand Up @@ -8645,7 +8720,9 @@ def _expect_file_path(taskdir: Path, path: str) -> Path:
return candidate if candidate.is_absolute() else taskdir / candidate

@staticmethod
async def _run_check(command: str, cwd: Path) -> tuple[int | None, bool, str]:
async def _run_check(
command: str, cwd: Path, timeout_s: int = CHECK_TIMEOUT_S
) -> tuple[int | None, bool, str]:
proc = await asyncio.create_subprocess_shell(
command,
cwd=str(cwd),
Expand All @@ -8656,7 +8733,7 @@ async def _run_check(command: str, cwd: Path) -> tuple[int | None, bool, str]:
)
timed_out = False
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=CHECK_TIMEOUT_S)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
timed_out = True
terminate_process_group(proc)
Expand All @@ -8667,7 +8744,7 @@ async def _run_check(command: str, cwd: Path) -> tuple[int | None, bool, str]:
stdout, _ = await proc.communicate()
output = stdout.decode("utf-8", errors="replace") if stdout else ""
if timed_out:
output += f"\n[ringer.py] check timed out after {CHECK_TIMEOUT_S}s\n"
output += f"\n[ringer.py] check timed out after {timeout_s}s\n"
return proc.returncode, timed_out, output


Expand Down Expand Up @@ -10056,6 +10133,7 @@ def dry_run(
print(f" dir: {taskdir}")
print(f" timeout_s: {task.timeout_s}")
print(f" max_attempts: {task.max_attempts}")
print(f" check_timeout_s: {task.check_timeout_s}")
if task.full_access:
print(f" full_access: true allowed={full_access_allowed}")
else:
Expand Down
1 change: 1 addition & 0 deletions templates/doc-swarm/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"{{DOCS_DIR}}/{{DOC_FILE}}"
],
"timeout_s": 1800,
"check_timeout_s": 1800,
"verified": "the doc has the required sections and substance, documented symbols exist in source, and every configured runnable example executes"
}
]
Expand Down
1 change: 1 addition & 0 deletions templates/fix-swarm/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"check": "python3 '{{CHECK_SCRIPT_PATH — absolute path to templates/fix-swarm/checks/fix-swarm.py}}' --verify-command '{{BUILD_OR_TEST_COMMAND — exact command that proves the fix and prints useful errors}}' --patch '{{WORKDIR}}/{{FIX_KEY}}.patch' --summary fix-summary.md --exported-summary '{{WORKDIR}}/{{FIX_KEY}}.summary.md' --owned-files '{{OWNED_FILES — every file or directory this task may modify, comma or newline separated}}'",
"expect_files": [],
"timeout_s": 1800,
"check_timeout_s": 1800,
"verified": "the validator ran the requested build or test command, exported a non-empty patch, and confirmed the patch only changes declared owned files"
}
]
Expand Down
1 change: 1 addition & 0 deletions templates/repo-feature/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"engine": "{{ENGINE_BUILD}}",
"task_type": "code-feature",
"timeout_s": 2400,
"check_timeout_s": 2400,
"expect_files": [
"notes.md"
],
Expand Down
1 change: 1 addition & 0 deletions templates/test-hardening/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"check": "{{PYTHON}} '{{KIT_DIR}}/checks/test_hardening_check.py' --task-key '{{TEST_KEY}}' --test-command '{{TEST_COMMAND}}' --baseline-test-count '{{BASELINE_TEST_COUNT}}' --test-count-regex '{{TEST_COUNT_REGEX}}' --new-test-files '{{NEW_TEST_FILES}}' --owned-test-files '{{OWNED_TEST_FILES — semicolon-separated repo-relative test files or test directory prefixes this worker may modify}}' --forbidden-paths '{{FORBIDDEN_PATHS}}' --assertion-pattern '{{ASSERTION_PATTERN}}' --min-assertions-per-file '{{MIN_ASSERTIONS_PER_FILE}}' --min-assertion-density '{{MIN_ASSERTION_DENSITY}}' --export-patch '{{EXPORT_DIR}}/{{TEST_KEY}}.patch' || { echo 'FAIL: test hardening validation failed for {{TEST_KEY}}'; exit 1; }",
"expect_files": [],
"timeout_s": 2400,
"check_timeout_s": 2400,
"verified": "the configured test command passed, the runner summary reported more tests than baseline, new owned test files exist with assertions, production paths were untouched, and a scoped patch was exported"
}
]
Expand Down
44 changes: 44 additions & 0 deletions tests/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,50 @@ def test_w9_missing_expect_files(self) -> None:
f"worktrees manifest should not be flagged for expect_files: {findings}",
)

def test_w10_build_shaped_check_without_raised_check_timeout(self) -> None:
expected = (
"one: check runs 'xcodebuild' but check_timeout_s is not raised; "
"builds and test suites rarely finish inside the 60s default — "
"set check_timeout_s (up to 3600)."
)
slow_check = "xcodebuild test -scheme App || { echo 'FAIL: xcodebuild test failed'; exit 1; }"

findings = lint_manifest(self.manifest([self.task(check=slow_check)]))
self.assertHasFinding(findings, expected)

raised_task = self.task(check=slow_check)
raised_task["check_timeout_s"] = 1200
self.assertNotIn(expected, lint_manifest(self.manifest([raised_task])))

subcommand_findings = lint_manifest(
self.manifest(
[self.task(check="swift test --parallel || { echo 'FAIL: swift test failed'; exit 1; }")]
)
)
self.assertTrue(
any("check runs 'swift test'" in item for item in subcommand_findings),
f"expected swift test finding, got: {subcommand_findings}",
)

# A tool name that only appears as an argument or inside an echo
# string is not the check's own command — no nudge.
wrapped = lint_manifest(
self.manifest(
[
self.task(
check=(
"python3 run_check.py --build-command 'xcodebuild test' || "
"{ echo 'FAIL: xcodebuild wrapper failed'; exit 1; }"
)
)
]
)
)
self.assertFalse(
any("check_timeout_s is not raised" in item for item in wrapped),
f"wrapped build command should not be flagged: {wrapped}",
)

def test_compliant_manifest_is_clean(self) -> None:
manifest = self.manifest(
[
Expand Down
89 changes: 81 additions & 8 deletions tests/test_ringer.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,20 +577,93 @@ def test_final_state_file_is_finished_after_passing_run(self) -> None:


def test_check_timeout_is_reported_separately_from_worker_timeout(self) -> None:
original_timeout = ringer.CHECK_TIMEOUT_S
ringer.CHECK_TIMEOUT_S = 1
with tempfile.TemporaryDirectory(prefix="ringer-check-timeout-") as tmp:
try:
returncode, timed_out, output = asyncio.run(
ringer.Verifier._run_check("sleep 5", Path(tmp))
)
finally:
ringer.CHECK_TIMEOUT_S = original_timeout
returncode, timed_out, output = asyncio.run(
ringer.Verifier._run_check("sleep 5", Path(tmp), timeout_s=1)
)

self.assertTrue(timed_out)
self.assertNotEqual(returncode, 0)
self.assertIn("[ringer.py] check timed out after 1s", output)

def test_verifier_uses_the_tasks_check_timeout(self) -> None:
task = ringer.TaskSpec(
key="slow-check",
spec="Irrelevant; only the check runs here.",
check="sleep 5",
check_timeout_s=1,
)
with tempfile.TemporaryDirectory(prefix="ringer-check-timeout-") as tmp:
verify = asyncio.run(ringer.Verifier().verify(task, Path(tmp)))

self.assertFalse(verify.ok)
self.assertTrue(verify.check_timed_out)
self.assertIn("[ringer.py] check timed out after 1s", verify.raw_output_excerpt)

def test_raised_check_timeout_lets_a_slow_check_finish(self) -> None:
# Regression for run ipad-hover-sweep (2026-08-02): the manifest set
# check_timeout_s but parsing dropped the field, so every slow check
# was killed at the module default. Shrink the default below the
# check's runtime — the task's raised limit must govern, not the
# default, and the check must be allowed to finish and pass.
task = ringer.TaskSpec.from_obj(
{
"key": "slow-but-raised",
"spec": "Irrelevant; only the check runs here.",
"check": "sleep 2",
"check_timeout_s": 30,
}
)
original = ringer.CHECK_TIMEOUT_S
ringer.CHECK_TIMEOUT_S = 1
try:
with tempfile.TemporaryDirectory(prefix="ringer-check-timeout-") as tmp:
verify = asyncio.run(ringer.Verifier().verify(task, Path(tmp)))
finally:
ringer.CHECK_TIMEOUT_S = original

self.assertTrue(verify.ok, verify.raw_output_excerpt)
self.assertFalse(verify.check_timed_out)
self.assertNotIn("check timed out", verify.raw_output_excerpt)

def test_check_timeout_s_parses_validates_and_defaults(self) -> None:
base: dict[str, object] = {
"key": "t",
"spec": "Write the artifact and keep the change scoped to this task directory.",
"check": "test -s out.txt || { echo 'FAIL: out.txt missing'; exit 1; }",
}
self.assertEqual(ringer.TaskSpec.from_obj(dict(base)).check_timeout_s, ringer.CHECK_TIMEOUT_S)
self.assertEqual(ringer.TaskSpec.from_obj(dict(base, check_timeout_s=1800)).check_timeout_s, 1800)
with self.assertRaisesRegex(ValueError, r"task t: check_timeout_s must be positive"):
ringer.TaskSpec.from_obj(dict(base, check_timeout_s=0))
with self.assertRaisesRegex(ValueError, r"task t: check_timeout_s must be <= 3600"):
ringer.TaskSpec.from_obj(dict(base, check_timeout_s=3601))

def test_check_timeout_s_from_manifest_governs_the_check_kill_timer(self) -> None:
manifest = self.write_manifest(
"check-timeout",
self.manifest(
"check-timeout",
{
"key": "slow-check",
"engine": "write_done",
"spec": "Write the file; the check itself is the slow part.",
"expect_files": ["out.txt"],
"check_timeout_s": 1,
"check": "sleep 5",
},
),
)

result = self.run_ringer(manifest, timeout=20)

self.assertEqual(result.returncode, 1, result.stdout)
rows = self.read_rows()
self.assertEqual([row["verdict"] for row in rows], ["TIMEOUT", "TIMEOUT"])
state = self.read_final_state()
self.assertTrue(state["tasks"][0]["check_timed_out"])
self.assertIn("[ringer.py] check timed out after 1s", state["tasks"][0]["check_output_tail"])

def test_token_count_parser_accepts_colon_and_newline_formats(self) -> None:
self.assertEqual(ringer.parse_token_count("tokens used: 1,234", r"tokens\s+used\s*:?\s*([0-9][0-9,]*)"), 1234)
self.assertEqual(ringer.parse_token_count("tokens used\n5,678", r"tokens\s+used\s*:?\s*([0-9][0-9,]*)"), 5678)
Expand Down