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
+77
-13
@@ -5,6 +5,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
@@ -31,6 +32,7 @@ class CommandResult:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,6 +44,9 @@ class EngineConfig:
|
||||
agent_command: str
|
||||
test_command: str
|
||||
base_branch: str = "main"
|
||||
agent_timeout_seconds: float = 1800.0
|
||||
test_timeout_seconds: float = 900.0
|
||||
termination_grace_seconds: float = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -63,7 +68,14 @@ class Client(Protocol):
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult: ...
|
||||
|
||||
|
||||
class StateStore:
|
||||
@@ -124,19 +136,38 @@ def parse_command(command: str, name: str) -> list[str]:
|
||||
|
||||
|
||||
class ShellRunner:
|
||||
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
completed = subprocess.run(
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=merged_env,
|
||||
shell=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
||||
return CommandResult(process.returncode, stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=termination_grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
stdout, stderr = process.communicate()
|
||||
return CommandResult(process.returncode, stdout, stderr, timed_out=True)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
@@ -207,6 +238,15 @@ class ReleaseEngine:
|
||||
self.store = StateStore(config.state_path)
|
||||
|
||||
def execute(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
|
||||
deadlines = {
|
||||
"agent timeout seconds": self.config.agent_timeout_seconds,
|
||||
"test timeout seconds": self.config.test_timeout_seconds,
|
||||
"termination grace seconds": self.config.termination_grace_seconds,
|
||||
}
|
||||
for name, value in deadlines.items():
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
|
||||
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
|
||||
branch = branch_name(self.config.agent, issue)
|
||||
|
||||
@@ -245,20 +285,38 @@ class ReleaseEngine:
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
|
||||
agent_result = self.runner.run(agent_command, self.config.repo_path, {
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
})
|
||||
agent_result = self.runner.run(
|
||||
agent_command,
|
||||
self.config.repo_path,
|
||||
{
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
},
|
||||
timeout_seconds=self.config.agent_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
if agent_result.timed_out:
|
||||
evidence = (agent_result.stdout + "\n" + agent_result.stderr).strip()[-2000:]
|
||||
self.store.save(RunState(issue.number, branch, "agent_timed_out", evidence))
|
||||
raise RuntimeError(f"coding agent timed out: {evidence}")
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "agent_complete"))
|
||||
|
||||
test_result = self.runner.run(test_command, self.config.repo_path)
|
||||
test_result = self.runner.run(
|
||||
test_command,
|
||||
self.config.repo_path,
|
||||
timeout_seconds=self.config.test_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.timed_out:
|
||||
self.store.save(RunState(issue.number, branch, "tests_timed_out", evidence))
|
||||
raise RuntimeError(f"tests timed out: {evidence}")
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
@@ -315,6 +373,9 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--api", default=os.getenv("GITEA_API", "http://127.0.0.1:3000/api/v1"))
|
||||
parser.add_argument("--agent-command", default=os.getenv("RELEASE_AGENT_COMMAND", ""))
|
||||
parser.add_argument("--test-command", default=os.getenv("RELEASE_TEST_COMMAND", "python3 -m pytest tests/ -q"))
|
||||
parser.add_argument("--agent-timeout", type=float, default=float(os.getenv("RELEASE_AGENT_TIMEOUT", "1800")))
|
||||
parser.add_argument("--test-timeout", type=float, default=float(os.getenv("RELEASE_TEST_TIMEOUT", "900")))
|
||||
parser.add_argument("--termination-grace", type=float, default=float(os.getenv("RELEASE_TERMINATION_GRACE", "5")))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -332,6 +393,9 @@ def main() -> int:
|
||||
state_path=args.state,
|
||||
agent_command=args.agent_command,
|
||||
test_command=args.test_command,
|
||||
agent_timeout_seconds=args.agent_timeout,
|
||||
test_timeout_seconds=args.test_timeout,
|
||||
termination_grace_seconds=args.termination_grace,
|
||||
)
|
||||
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
|
||||
issue_number=args.issue,
|
||||
|
||||
Reference in new issue
Block a user