autopilot-code 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/package.json +1 -1
- package/scripts/issue_runner/agents/__init__.py +10 -2
- package/scripts/issue_runner/agents/opencode_client.py +486 -0
- package/scripts/issue_runner/agents/opencode_server.py +247 -0
- package/scripts/issue_runner/agents/test_opencode_server.py +321 -0
- package/scripts/issue_runner/runner.py +11 -0
- package/scripts/run_autopilot.py +293 -149
- package/templates/autopilot.json +0 -1
- package/scripts/run_opencode_issue.sh +0 -690
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCode server-based agent implementation.
|
|
3
|
+
|
|
4
|
+
This agent uses the OpenCode HTTP server API instead of the CLI,
|
|
5
|
+
providing proper session management and continuity across multiple
|
|
6
|
+
agent calls within the same issue workflow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .base import BaseAgent, AgentResult
|
|
16
|
+
from .opencode_client import OpenCodeClient, OpenCodeServerManager
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Global server manager shared across agent instances.
|
|
21
|
+
# This is intentionally global (not a class variable or injected) because:
|
|
22
|
+
# 1. Multiple IssueRunner instances may create separate agent instances
|
|
23
|
+
# 2. The manager must track ALL running servers to properly reuse/cleanup
|
|
24
|
+
# 3. Python's GIL makes this safe for our single-threaded runner
|
|
25
|
+
_server_manager: Optional[OpenCodeServerManager] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_server_manager(binary_path: str = "opencode") -> OpenCodeServerManager:
|
|
29
|
+
"""Get or create the global server manager."""
|
|
30
|
+
global _server_manager
|
|
31
|
+
if _server_manager is None:
|
|
32
|
+
_server_manager = OpenCodeServerManager(binary_path)
|
|
33
|
+
return _server_manager
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reset_server_manager() -> None:
|
|
37
|
+
"""Reset the global server manager. Used for testing."""
|
|
38
|
+
global _server_manager
|
|
39
|
+
if _server_manager is not None:
|
|
40
|
+
_server_manager.stop_all()
|
|
41
|
+
_server_manager = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OpenCodeServerAgent(BaseAgent):
|
|
45
|
+
"""
|
|
46
|
+
Agent implementation using OpenCode HTTP server.
|
|
47
|
+
|
|
48
|
+
Unlike the CLI-based agent, this implementation:
|
|
49
|
+
- Starts an OpenCode server per worktree
|
|
50
|
+
- Maintains proper session continuity via session IDs
|
|
51
|
+
- Persists sessions across server restarts (sessions stored in worktree)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: dict):
|
|
55
|
+
super().__init__(config)
|
|
56
|
+
self._server_manager: Optional[OpenCodeServerManager] = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return "OpenCode Server"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def supports_sessions(self) -> bool:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def server_manager(self) -> OpenCodeServerManager:
|
|
68
|
+
"""Get the server manager, initializing if needed.
|
|
69
|
+
|
|
70
|
+
Note: self.binary_path is inherited from BaseAgent which caches
|
|
71
|
+
the result of find_binary() on first access.
|
|
72
|
+
"""
|
|
73
|
+
if self._server_manager is None:
|
|
74
|
+
self._server_manager = get_server_manager(self.binary_path)
|
|
75
|
+
return self._server_manager
|
|
76
|
+
|
|
77
|
+
def find_binary(self) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Locate the opencode binary.
|
|
80
|
+
|
|
81
|
+
Search order:
|
|
82
|
+
1. agentPath from config
|
|
83
|
+
2. PATH
|
|
84
|
+
3. Common nvm locations
|
|
85
|
+
4. Other common locations
|
|
86
|
+
"""
|
|
87
|
+
# 1. Config-specified path
|
|
88
|
+
agent_path = self.config.get("agentPath", "")
|
|
89
|
+
if agent_path and os.path.isfile(agent_path) and os.access(agent_path, os.X_OK):
|
|
90
|
+
return agent_path
|
|
91
|
+
|
|
92
|
+
# 2. Already in PATH
|
|
93
|
+
which_result = shutil.which("opencode")
|
|
94
|
+
if which_result:
|
|
95
|
+
return which_result
|
|
96
|
+
|
|
97
|
+
# 3. Common nvm locations
|
|
98
|
+
home = Path.home()
|
|
99
|
+
nvm_dir = home / ".nvm" / "versions" / "node"
|
|
100
|
+
if nvm_dir.exists():
|
|
101
|
+
for node_dir in nvm_dir.iterdir():
|
|
102
|
+
opencode_path = node_dir / "bin" / "opencode"
|
|
103
|
+
if opencode_path.exists() and os.access(opencode_path, os.X_OK):
|
|
104
|
+
return str(opencode_path)
|
|
105
|
+
|
|
106
|
+
# 4. Other common locations
|
|
107
|
+
common_paths = [
|
|
108
|
+
home / ".local" / "bin" / "opencode",
|
|
109
|
+
Path("/usr/local/bin/opencode"),
|
|
110
|
+
home / ".npm-global" / "bin" / "opencode",
|
|
111
|
+
]
|
|
112
|
+
for path in common_paths:
|
|
113
|
+
if path.exists() and os.access(path, os.X_OK):
|
|
114
|
+
return str(path)
|
|
115
|
+
|
|
116
|
+
raise FileNotFoundError(
|
|
117
|
+
"opencode not found. Set 'agentPath' in autopilot.json or ensure opencode is installed."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _get_client(self, worktree: Path) -> OpenCodeClient:
|
|
121
|
+
"""
|
|
122
|
+
Get an OpenCode client for a worktree, starting server if needed.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
worktree: Path to the worktree
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
OpenCodeClient connected to server
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
RuntimeError: If server couldn't be started
|
|
132
|
+
"""
|
|
133
|
+
client = self.server_manager.get_client(worktree)
|
|
134
|
+
if client is None:
|
|
135
|
+
raise RuntimeError(f"Failed to start OpenCode server for {worktree}")
|
|
136
|
+
return client
|
|
137
|
+
|
|
138
|
+
def _ensure_session(
|
|
139
|
+
self,
|
|
140
|
+
client: OpenCodeClient,
|
|
141
|
+
session_id: Optional[str],
|
|
142
|
+
title: Optional[str] = None,
|
|
143
|
+
) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Ensure a valid session exists, creating one if needed.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
client: OpenCode client
|
|
149
|
+
session_id: Existing session ID (if any)
|
|
150
|
+
title: Title for new session
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Valid session ID
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RuntimeError: If session couldn't be created
|
|
157
|
+
"""
|
|
158
|
+
# Check if existing session is valid
|
|
159
|
+
if session_id and client.session_exists(session_id):
|
|
160
|
+
logger.info(f"Reusing existing session: {session_id}")
|
|
161
|
+
return session_id
|
|
162
|
+
|
|
163
|
+
# Create new session
|
|
164
|
+
new_session_id = client.create_session(title=title)
|
|
165
|
+
if not new_session_id:
|
|
166
|
+
raise RuntimeError("Failed to create OpenCode session")
|
|
167
|
+
|
|
168
|
+
logger.info(f"Created new session: {new_session_id}")
|
|
169
|
+
return new_session_id
|
|
170
|
+
|
|
171
|
+
def run(
|
|
172
|
+
self,
|
|
173
|
+
worktree: Path,
|
|
174
|
+
prompt: str,
|
|
175
|
+
session_id: Optional[str] = None,
|
|
176
|
+
) -> AgentResult:
|
|
177
|
+
"""
|
|
178
|
+
Run OpenCode with the given prompt.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
worktree: Working directory for the agent
|
|
182
|
+
prompt: The task/prompt
|
|
183
|
+
session_id: Previous session ID to continue (if any)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
AgentResult with session_id for future continuation
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
# Get client (starts server if needed)
|
|
190
|
+
client = self._get_client(worktree)
|
|
191
|
+
|
|
192
|
+
# Ensure we have a valid session
|
|
193
|
+
session_id = self._ensure_session(client, session_id, title="autopilot")
|
|
194
|
+
|
|
195
|
+
# Send message
|
|
196
|
+
logger.info(f"Sending message to session {session_id}")
|
|
197
|
+
response = client.send_message(session_id, prompt)
|
|
198
|
+
|
|
199
|
+
if response is None:
|
|
200
|
+
return AgentResult(
|
|
201
|
+
success=False,
|
|
202
|
+
session_id=session_id,
|
|
203
|
+
output="",
|
|
204
|
+
error="No response from OpenCode server",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Extract text from response
|
|
208
|
+
output_text = response.get_text()
|
|
209
|
+
|
|
210
|
+
# Check finish reason
|
|
211
|
+
success = response.finish_reason in ("stop", "tool-calls", None)
|
|
212
|
+
|
|
213
|
+
return AgentResult(
|
|
214
|
+
success=success,
|
|
215
|
+
session_id=session_id,
|
|
216
|
+
output=output_text,
|
|
217
|
+
error=None if success else f"Unexpected finish: {response.finish_reason}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.exception("Error running OpenCode server agent")
|
|
222
|
+
return AgentResult(
|
|
223
|
+
success=False,
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
output="",
|
|
226
|
+
error=str(e),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def stop_server(self, worktree: Path) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Stop the server for a worktree.
|
|
232
|
+
|
|
233
|
+
Should be called during cleanup to release resources.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
worktree: Path to the worktree
|
|
237
|
+
"""
|
|
238
|
+
self.server_manager.stop_server(worktree)
|
|
239
|
+
|
|
240
|
+
def cleanup(self, worktree: Path) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Clean up resources for a worktree.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
worktree: Path to the worktree
|
|
246
|
+
"""
|
|
247
|
+
self.stop_server(worktree)
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Integration tests for OpenCode server agent.
|
|
4
|
+
|
|
5
|
+
Run with: python3 -m scripts.issue_runner.agents.test_opencode_server
|
|
6
|
+
|
|
7
|
+
These tests require OpenCode to be installed and configured.
|
|
8
|
+
|
|
9
|
+
NOTE: Some tests (session_continuity, session_persistence, concurrent_sessions)
|
|
10
|
+
rely on LLM responses and may occasionally fail due to non-deterministic model
|
|
11
|
+
output. This is expected - the tests verify real end-to-end behavior rather
|
|
12
|
+
than mocking. Re-run if a single test fails sporadically.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Setup logging
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=logging.INFO,
|
|
24
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
25
|
+
)
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestResult:
|
|
30
|
+
def __init__(self, name: str):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.passed = False
|
|
33
|
+
self.error = None
|
|
34
|
+
|
|
35
|
+
def __str__(self):
|
|
36
|
+
status = "✅ PASS" if self.passed else f"❌ FAIL: {self.error}"
|
|
37
|
+
return f"{self.name}: {status}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_imports() -> TestResult:
|
|
41
|
+
"""Test that all imports work correctly."""
|
|
42
|
+
result = TestResult("imports")
|
|
43
|
+
try:
|
|
44
|
+
from scripts.issue_runner.agents import get_agent, OpenCodeServerAgent
|
|
45
|
+
from scripts.issue_runner.agents.opencode_client import (
|
|
46
|
+
OpenCodeClient,
|
|
47
|
+
OpenCodeServerManager,
|
|
48
|
+
ServerInfo,
|
|
49
|
+
MessageResponse,
|
|
50
|
+
)
|
|
51
|
+
result.passed = True
|
|
52
|
+
except Exception as e:
|
|
53
|
+
result.error = str(e)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_agent_factory() -> TestResult:
|
|
58
|
+
"""Test that the agent factory creates the correct agent type."""
|
|
59
|
+
result = TestResult("agent_factory")
|
|
60
|
+
try:
|
|
61
|
+
from scripts.issue_runner.agents import get_agent, OpenCodeServerAgent
|
|
62
|
+
|
|
63
|
+
agent = get_agent("opencode-server", {})
|
|
64
|
+
assert isinstance(agent, OpenCodeServerAgent), f"Wrong type: {type(agent)}"
|
|
65
|
+
assert agent.name == "OpenCode Server"
|
|
66
|
+
assert agent.supports_sessions is True
|
|
67
|
+
result.passed = True
|
|
68
|
+
except Exception as e:
|
|
69
|
+
result.error = str(e)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_find_binary() -> TestResult:
|
|
74
|
+
"""Test that the opencode binary can be found."""
|
|
75
|
+
result = TestResult("find_binary")
|
|
76
|
+
try:
|
|
77
|
+
from scripts.issue_runner.agents import get_agent
|
|
78
|
+
|
|
79
|
+
agent = get_agent("opencode-server", {})
|
|
80
|
+
binary = agent.find_binary()
|
|
81
|
+
assert binary is not None, "Binary not found"
|
|
82
|
+
assert Path(binary).exists(), f"Binary does not exist: {binary}"
|
|
83
|
+
result.passed = True
|
|
84
|
+
except FileNotFoundError as e:
|
|
85
|
+
result.error = f"OpenCode not installed: {e}"
|
|
86
|
+
except Exception as e:
|
|
87
|
+
result.error = str(e)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_server_start_stop(worktree: Path) -> TestResult:
|
|
92
|
+
"""Test starting and stopping a server."""
|
|
93
|
+
result = TestResult("server_start_stop")
|
|
94
|
+
try:
|
|
95
|
+
from scripts.issue_runner.agents.opencode_client import OpenCodeServerManager
|
|
96
|
+
|
|
97
|
+
manager = OpenCodeServerManager()
|
|
98
|
+
|
|
99
|
+
# Start server
|
|
100
|
+
info = manager.start_server(worktree)
|
|
101
|
+
assert info is not None, "Failed to start server"
|
|
102
|
+
assert info.port > 0, f"Invalid port: {info.port}"
|
|
103
|
+
assert info.pid > 0, f"Invalid PID: {info.pid}"
|
|
104
|
+
|
|
105
|
+
# Stop server
|
|
106
|
+
stopped = manager.stop_server(worktree)
|
|
107
|
+
assert stopped, "Failed to stop server"
|
|
108
|
+
|
|
109
|
+
result.passed = True
|
|
110
|
+
except Exception as e:
|
|
111
|
+
result.error = str(e)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_session_creation(worktree: Path) -> TestResult:
|
|
116
|
+
"""Test creating a session."""
|
|
117
|
+
result = TestResult("session_creation")
|
|
118
|
+
try:
|
|
119
|
+
from scripts.issue_runner.agents import get_agent
|
|
120
|
+
|
|
121
|
+
agent = get_agent("opencode-server", {})
|
|
122
|
+
|
|
123
|
+
# Run a simple command to create session
|
|
124
|
+
res = agent.run(worktree, "What is 1+1? Reply with just the number.")
|
|
125
|
+
|
|
126
|
+
assert res.success, f"Agent failed: {res.error}"
|
|
127
|
+
assert res.session_id is not None, "No session ID returned"
|
|
128
|
+
# OpenCode uses "ses_" prefix as of v1.x - if this fails, check version compatibility
|
|
129
|
+
assert res.session_id.startswith("ses_"), f"Invalid session ID: {res.session_id}"
|
|
130
|
+
|
|
131
|
+
agent.cleanup(worktree)
|
|
132
|
+
result.passed = True
|
|
133
|
+
except Exception as e:
|
|
134
|
+
result.error = str(e)
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_session_continuity(worktree: Path) -> TestResult:
|
|
139
|
+
"""Test that session continuity works within a single server."""
|
|
140
|
+
result = TestResult("session_continuity")
|
|
141
|
+
try:
|
|
142
|
+
from scripts.issue_runner.agents import get_agent
|
|
143
|
+
|
|
144
|
+
agent = get_agent("opencode-server", {})
|
|
145
|
+
|
|
146
|
+
# First message - set a secret
|
|
147
|
+
res1 = agent.run(worktree, "Remember: the password is ELEPHANT. Just say OK.")
|
|
148
|
+
assert res1.success, f"First message failed: {res1.error}"
|
|
149
|
+
session_id = res1.session_id
|
|
150
|
+
|
|
151
|
+
# Second message - recall the secret
|
|
152
|
+
res2 = agent.run(worktree, "What was the password?", session_id=session_id)
|
|
153
|
+
assert res2.success, f"Second message failed: {res2.error}"
|
|
154
|
+
assert res2.session_id == session_id, "Session ID changed"
|
|
155
|
+
assert "ELEPHANT" in res2.output.upper(), f"Password not recalled: {res2.output}"
|
|
156
|
+
|
|
157
|
+
agent.cleanup(worktree)
|
|
158
|
+
result.passed = True
|
|
159
|
+
except Exception as e:
|
|
160
|
+
result.error = str(e)
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_session_persistence(worktree: Path) -> TestResult:
|
|
165
|
+
"""Test that sessions persist across server restarts."""
|
|
166
|
+
result = TestResult("session_persistence")
|
|
167
|
+
try:
|
|
168
|
+
from scripts.issue_runner.agents import get_agent
|
|
169
|
+
|
|
170
|
+
# First agent - set secret and stop
|
|
171
|
+
agent1 = get_agent("opencode-server", {})
|
|
172
|
+
res1 = agent1.run(worktree, "Remember: the code is ZEBRA. Just say OK.")
|
|
173
|
+
assert res1.success, f"First message failed: {res1.error}"
|
|
174
|
+
session_id = res1.session_id
|
|
175
|
+
|
|
176
|
+
# Stop the server
|
|
177
|
+
agent1.cleanup(worktree)
|
|
178
|
+
|
|
179
|
+
# Second agent - recall secret with same session ID
|
|
180
|
+
agent2 = get_agent("opencode-server", {})
|
|
181
|
+
res2 = agent2.run(worktree, "What was the code?", session_id=session_id)
|
|
182
|
+
assert res2.success, f"Second message failed: {res2.error}"
|
|
183
|
+
assert res2.session_id == session_id, "Session ID changed after restart"
|
|
184
|
+
assert "ZEBRA" in res2.output.upper(), f"Code not recalled after restart: {res2.output}"
|
|
185
|
+
|
|
186
|
+
agent2.cleanup(worktree)
|
|
187
|
+
result.passed = True
|
|
188
|
+
except Exception as e:
|
|
189
|
+
result.error = str(e)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_concurrent_sessions(worktree1: Path, worktree2: Path) -> TestResult:
|
|
194
|
+
"""Test that multiple worktrees have independent sessions.
|
|
195
|
+
|
|
196
|
+
"Concurrent" here means multiple worktrees can have active sessions
|
|
197
|
+
simultaneously (each with its own server), not parallel thread execution.
|
|
198
|
+
This verifies session isolation - each worktree remembers its own context.
|
|
199
|
+
"""
|
|
200
|
+
result = TestResult("concurrent_sessions")
|
|
201
|
+
try:
|
|
202
|
+
from scripts.issue_runner.agents import get_agent
|
|
203
|
+
|
|
204
|
+
agent = get_agent("opencode-server", {})
|
|
205
|
+
|
|
206
|
+
# Create sessions in both worktrees (runs sequentially but sessions coexist)
|
|
207
|
+
res1 = agent.run(worktree1, "Remember: worktree1 secret is APPLE. Say OK.")
|
|
208
|
+
res2 = agent.run(worktree2, "Remember: worktree2 secret is BANANA. Say OK.")
|
|
209
|
+
|
|
210
|
+
assert res1.success, f"Worktree1 failed: {res1.error}"
|
|
211
|
+
assert res2.success, f"Worktree2 failed: {res2.error}"
|
|
212
|
+
assert res1.session_id != res2.session_id, "Sessions should be different"
|
|
213
|
+
|
|
214
|
+
# Verify each remembers its own secret
|
|
215
|
+
res1b = agent.run(worktree1, "What was the secret?", session_id=res1.session_id)
|
|
216
|
+
res2b = agent.run(worktree2, "What was the secret?", session_id=res2.session_id)
|
|
217
|
+
|
|
218
|
+
assert "APPLE" in res1b.output.upper(), f"Worktree1 wrong: {res1b.output}"
|
|
219
|
+
assert "BANANA" in res2b.output.upper(), f"Worktree2 wrong: {res2b.output}"
|
|
220
|
+
|
|
221
|
+
agent.cleanup(worktree1)
|
|
222
|
+
agent.cleanup(worktree2)
|
|
223
|
+
result.passed = True
|
|
224
|
+
except Exception as e:
|
|
225
|
+
result.error = str(e)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def run_tests():
|
|
230
|
+
"""Run all tests and report results."""
|
|
231
|
+
from scripts.issue_runner.agents.opencode_server import reset_server_manager
|
|
232
|
+
|
|
233
|
+
print("\n" + "=" * 60)
|
|
234
|
+
print("OpenCode Server Agent - Integration Tests")
|
|
235
|
+
print("=" * 60 + "\n")
|
|
236
|
+
|
|
237
|
+
results = []
|
|
238
|
+
|
|
239
|
+
# Basic tests that don't need a worktree
|
|
240
|
+
print("Running basic tests...")
|
|
241
|
+
results.append(test_imports())
|
|
242
|
+
results.append(test_agent_factory())
|
|
243
|
+
results.append(test_find_binary())
|
|
244
|
+
|
|
245
|
+
# Check if we can proceed with integration tests
|
|
246
|
+
if not all(r.passed for r in results):
|
|
247
|
+
print("\n⚠️ Basic tests failed, skipping integration tests\n")
|
|
248
|
+
else:
|
|
249
|
+
# Create temporary worktrees for integration tests
|
|
250
|
+
print("\nRunning integration tests (requires OpenCode)...")
|
|
251
|
+
|
|
252
|
+
# Reset global state before integration tests
|
|
253
|
+
reset_server_manager()
|
|
254
|
+
|
|
255
|
+
# Use the current repo as worktree for single-worktree tests
|
|
256
|
+
worktree = Path(__file__).parent.parent.parent.parent.resolve()
|
|
257
|
+
|
|
258
|
+
results.append(test_server_start_stop(worktree))
|
|
259
|
+
reset_server_manager() # Clean state between tests
|
|
260
|
+
|
|
261
|
+
results.append(test_session_creation(worktree))
|
|
262
|
+
reset_server_manager()
|
|
263
|
+
|
|
264
|
+
results.append(test_session_continuity(worktree))
|
|
265
|
+
reset_server_manager()
|
|
266
|
+
|
|
267
|
+
results.append(test_session_persistence(worktree))
|
|
268
|
+
reset_server_manager()
|
|
269
|
+
|
|
270
|
+
# For concurrent test, we need two different directories
|
|
271
|
+
# Use temp directories that are git repos
|
|
272
|
+
temp1 = None
|
|
273
|
+
temp2 = None
|
|
274
|
+
try:
|
|
275
|
+
temp1 = Path(tempfile.mkdtemp(prefix="autopilot-test1-"))
|
|
276
|
+
temp2 = Path(tempfile.mkdtemp(prefix="autopilot-test2-"))
|
|
277
|
+
|
|
278
|
+
# Initialize as git repos (required by opencode)
|
|
279
|
+
import subprocess
|
|
280
|
+
subprocess.run(["git", "init"], cwd=temp1, capture_output=True)
|
|
281
|
+
subprocess.run(["git", "init"], cwd=temp2, capture_output=True)
|
|
282
|
+
|
|
283
|
+
results.append(test_concurrent_sessions(temp1, temp2))
|
|
284
|
+
except Exception as e:
|
|
285
|
+
result = TestResult("concurrent_sessions")
|
|
286
|
+
result.error = f"Setup failed: {e}"
|
|
287
|
+
results.append(result)
|
|
288
|
+
finally:
|
|
289
|
+
# Always cleanup temp dirs and reset server state
|
|
290
|
+
reset_server_manager()
|
|
291
|
+
if temp1:
|
|
292
|
+
shutil.rmtree(temp1, ignore_errors=True)
|
|
293
|
+
if temp2:
|
|
294
|
+
shutil.rmtree(temp2, ignore_errors=True)
|
|
295
|
+
|
|
296
|
+
# Print results
|
|
297
|
+
print("\n" + "-" * 60)
|
|
298
|
+
print("Results:")
|
|
299
|
+
print("-" * 60)
|
|
300
|
+
|
|
301
|
+
for r in results:
|
|
302
|
+
print(f" {r}")
|
|
303
|
+
|
|
304
|
+
passed = sum(1 for r in results if r.passed)
|
|
305
|
+
total = len(results)
|
|
306
|
+
|
|
307
|
+
print("-" * 60)
|
|
308
|
+
print(f"\n{'✅' if passed == total else '❌'} {passed}/{total} tests passed\n")
|
|
309
|
+
|
|
310
|
+
return passed == total
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
# Change to repo root so imports work
|
|
315
|
+
import os
|
|
316
|
+
repo_root = Path(__file__).parent.parent.parent.parent.resolve()
|
|
317
|
+
os.chdir(repo_root)
|
|
318
|
+
sys.path.insert(0, str(repo_root))
|
|
319
|
+
|
|
320
|
+
success = run_tests()
|
|
321
|
+
sys.exit(0 if success else 1)
|
|
@@ -81,6 +81,7 @@ class IssueRunner:
|
|
|
81
81
|
updated_at=datetime.utcnow().isoformat() + "Z",
|
|
82
82
|
)
|
|
83
83
|
self._save_state(state, f"❌ Failed at step {state.step.value}: {e}")
|
|
84
|
+
self._cleanup_agent(Path(state.worktree))
|
|
84
85
|
return False
|
|
85
86
|
|
|
86
87
|
return state.step == IssueStep.DONE
|
|
@@ -110,6 +111,14 @@ class IssueRunner:
|
|
|
110
111
|
message = STEP_STATUS_MESSAGES.get(state.step, f"Step: {state.step.value}")
|
|
111
112
|
self.github.save_state(state.issue_number, state, message)
|
|
112
113
|
|
|
114
|
+
def _cleanup_agent(self, worktree: Path) -> None:
|
|
115
|
+
"""Clean up agent resources if supported."""
|
|
116
|
+
if hasattr(self.agent, "cleanup"):
|
|
117
|
+
try:
|
|
118
|
+
self.agent.cleanup(worktree)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Error cleaning up agent: {e}")
|
|
121
|
+
|
|
113
122
|
def _transition(self, state: StateData) -> StateData:
|
|
114
123
|
"""Execute one state transition."""
|
|
115
124
|
handlers = {
|
|
@@ -672,6 +681,8 @@ This PR is automatically created by Autopilot to implement issue #{state.issue_n
|
|
|
672
681
|
|
|
673
682
|
# Cleanup worktree
|
|
674
683
|
worktree = Path(state.worktree)
|
|
684
|
+
self._cleanup_agent(worktree)
|
|
685
|
+
|
|
675
686
|
if self.git.worktree_exists(worktree):
|
|
676
687
|
self.git.remove_worktree(worktree)
|
|
677
688
|
|