feat: bound autonomous release command runtime (#329)
This commit is contained in:
1 parent
41eaf51953
commit
fb6b42bcaf
4 files changed
+216
-15
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +17,7 @@ from src.release_engine import (
|
||||
RunState,
|
||||
ShellRunner,
|
||||
StateStore,
|
||||
_parse_args,
|
||||
branch_name,
|
||||
select_issue,
|
||||
)
|
||||
@@ -41,6 +44,33 @@ def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
|
||||
assert not injected.exists()
|
||||
|
||||
|
||||
def test_shell_runner_times_out_and_terminates_spawned_children(tmp_path):
|
||||
marker = tmp_path / "child-survived.txt"
|
||||
child = (
|
||||
"import time; from pathlib import Path; "
|
||||
f"time.sleep(0.3); Path({str(marker)!r}).write_text('survived')"
|
||||
)
|
||||
parent = (
|
||||
"import subprocess, sys, time; "
|
||||
f"subprocess.Popen([sys.executable, '-c', {child!r}]); "
|
||||
"time.sleep(10)"
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
result = ShellRunner().run(
|
||||
[sys.executable, "-c", parent],
|
||||
tmp_path,
|
||||
timeout_seconds=0.05,
|
||||
termination_grace_seconds=0.05,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
time.sleep(0.35)
|
||||
|
||||
assert result.timed_out is True
|
||||
assert elapsed < 0.5
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_stale_token_error_explains_how_to_restore_cron_authentication():
|
||||
class UnauthorizedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
@@ -94,10 +124,12 @@ class FakeRunner:
|
||||
self.results = list(results)
|
||||
self.commands = []
|
||||
self.environments = []
|
||||
self.options = []
|
||||
|
||||
def run(self, command, cwd, env=None):
|
||||
def run(self, command, cwd, env=None, **options):
|
||||
self.commands.append(command)
|
||||
self.environments.append(env)
|
||||
self.options.append(options)
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
@@ -164,6 +196,41 @@ def test_empty_agent_command_fails_before_claim(tmp_path):
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("agent_timeout_seconds", 0),
|
||||
("test_timeout_seconds", -1),
|
||||
("termination_grace_seconds", 0),
|
||||
],
|
||||
)
|
||||
def test_non_positive_command_deadlines_fail_before_claim(tmp_path, field, value):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = replace(config(tmp_path), **{field: value})
|
||||
|
||||
with pytest.raises(ValueError, match=field.replace("_", " ")):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
def test_command_deadlines_are_configurable_from_cli(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"release-engine",
|
||||
"--repo", "stackchain/stackchain-dashboard",
|
||||
"--agent-timeout", "120",
|
||||
"--test-timeout", "45.5",
|
||||
"--termination-grace", "2",
|
||||
])
|
||||
|
||||
args = _parse_args()
|
||||
|
||||
assert args.agent_timeout == 120
|
||||
assert args.test_timeout == 45.5
|
||||
assert args.termination_grace == 2
|
||||
|
||||
|
||||
def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -183,6 +250,33 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_agent_timeout_records_terminal_state_and_blocks_tests(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(-9, "partial output", "stalled", timed_out=True),
|
||||
])
|
||||
cfg = config(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="coding agent timed out"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
state = StateStore(cfg.state_path).load()
|
||||
assert state is not None
|
||||
assert state.status == "agent_timed_out"
|
||||
assert "partial output" in state.evidence
|
||||
assert runner.commands == [
|
||||
["git", "checkout", "-B", "timmy/14-engine"],
|
||||
["agent", "--issue-from-env"],
|
||||
]
|
||||
assert runner.options[1] == {
|
||||
"timeout_seconds": cfg.agent_timeout_seconds,
|
||||
"termination_grace_seconds": cfg.termination_grace_seconds,
|
||||
}
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
|
||||
title = "$(touch /tmp/title-injected); `id`"
|
||||
body = "line one\n&& touch /tmp/body-injected > stolen"
|
||||
@@ -229,6 +323,31 @@ def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_test_timeout_records_terminal_state_and_blocks_push(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(-15, "3 passed before stall", "", timed_out=True),
|
||||
])
|
||||
cfg = config(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="tests timed out"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
state = StateStore(cfg.state_path).load()
|
||||
assert state is not None
|
||||
assert state.status == "tests_timed_out"
|
||||
assert "3 passed before stall" in state.evidence
|
||||
assert runner.options[2] == {
|
||||
"timeout_seconds": cfg.test_timeout_seconds,
|
||||
"termination_grace_seconds": cfg.termination_grace_seconds,
|
||||
}
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
|
||||
issue = Issue(14, "Engine MVP", "body", "open", ["P1"], None)
|
||||
client = FakeClient([issue])
|
||||
|
||||
Reference in new issue
Block a user