pycastle 0.1.0__py3-none-any.whl
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.
- pycastle/__init__.py +0 -0
- pycastle/build_command.py +34 -0
- pycastle/claude_service.py +28 -0
- pycastle/config.py +34 -0
- pycastle/container_runner.py +465 -0
- pycastle/defaults/.gitignore +3 -0
- pycastle/defaults/Dockerfile +34 -0
- pycastle/defaults/config.py +47 -0
- pycastle/defaults/prompts/CODING_STANDARDS.md +83 -0
- pycastle/defaults/prompts/implement-prompt.md +126 -0
- pycastle/defaults/prompts/merge-prompt.md +18 -0
- pycastle/defaults/prompts/plan-prompt.md +35 -0
- pycastle/defaults/prompts/preflight-issue.md +102 -0
- pycastle/defaults/prompts/review-prompt.md +87 -0
- pycastle/docker_service.py +42 -0
- pycastle/errors.py +75 -0
- pycastle/git_service.py +209 -0
- pycastle/github_service.py +261 -0
- pycastle/init_command.py +116 -0
- pycastle/labels.py +176 -0
- pycastle/main.py +73 -0
- pycastle/orchestrator.py +504 -0
- pycastle/prompt_pipeline.py +42 -0
- pycastle/validate.py +99 -0
- pycastle/worktree.py +117 -0
- pycastle-0.1.0.dist-info/METADATA +89 -0
- pycastle-0.1.0.dist-info/RECORD +31 -0
- pycastle-0.1.0.dist-info/WHEEL +5 -0
- pycastle-0.1.0.dist-info/entry_points.txt +2 -0
- pycastle-0.1.0.dist-info/licenses/LICENSE +21 -0
- pycastle-0.1.0.dist-info/top_level.txt +1 -0
pycastle/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .config import DOCKERFILE, DOCKER_IMAGE_NAME
|
|
7
|
+
from .docker_service import DockerService
|
|
8
|
+
from .errors import DockerServiceError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(no_cache: bool = False, docker_service: DockerService | None = None) -> None:
|
|
12
|
+
if docker_service is None:
|
|
13
|
+
docker_service = DockerService()
|
|
14
|
+
|
|
15
|
+
python_version: str | None = None
|
|
16
|
+
python_version_file = Path(".python-version")
|
|
17
|
+
if python_version_file.exists():
|
|
18
|
+
version = python_version_file.read_text().strip()
|
|
19
|
+
parts = version.split(".")
|
|
20
|
+
python_version = ".".join(parts[:2]) if len(parts) >= 2 else version
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
docker_service.build_image(
|
|
24
|
+
DOCKER_IMAGE_NAME,
|
|
25
|
+
DOCKERFILE,
|
|
26
|
+
Path("."),
|
|
27
|
+
no_cache=no_cache,
|
|
28
|
+
python_version=python_version,
|
|
29
|
+
)
|
|
30
|
+
except DockerServiceError as exc:
|
|
31
|
+
print(str(exc), file=sys.stderr)
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
sys.exit(0)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from .errors import ClaudeCliNotFoundError
|
|
7
|
+
|
|
8
|
+
# claude CLI does not expose a list-models subcommand; this list is kept in sync manually.
|
|
9
|
+
_KNOWN_MODELS: tuple[str, ...] = (
|
|
10
|
+
"claude-haiku-4-5-20251001",
|
|
11
|
+
"claude-sonnet-4-6",
|
|
12
|
+
"claude-opus-4-7",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@lru_cache(maxsize=1)
|
|
17
|
+
def _list_models() -> tuple[str, ...]:
|
|
18
|
+
"""Return known Claude model IDs, verifying the CLI is installed. Cached for the process lifetime."""
|
|
19
|
+
if shutil.which("claude") is None:
|
|
20
|
+
raise ClaudeCliNotFoundError(
|
|
21
|
+
"claude CLI not found; ensure it is installed and on PATH"
|
|
22
|
+
)
|
|
23
|
+
return _KNOWN_MODELS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaudeService:
|
|
27
|
+
def list_models(self) -> tuple[str, ...]:
|
|
28
|
+
return _list_models()
|
pycastle/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import importlib.util as _util
|
|
2
|
+
import sys as _sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .defaults.config import ( # noqa: F401
|
|
6
|
+
DOCKERFILE,
|
|
7
|
+
DOCKER_IMAGE_NAME,
|
|
8
|
+
ENV_FILE,
|
|
9
|
+
HITL_LABEL,
|
|
10
|
+
IDLE_TIMEOUT,
|
|
11
|
+
IMPLEMENT_CHECKS,
|
|
12
|
+
ISSUE_LABEL,
|
|
13
|
+
LOGS_DIR,
|
|
14
|
+
MAX_ITERATIONS,
|
|
15
|
+
MAX_PARALLEL,
|
|
16
|
+
PREFLIGHT_CHECKS,
|
|
17
|
+
PROMPTS_DIR,
|
|
18
|
+
PYCASTLE_DIR,
|
|
19
|
+
STAGE_OVERRIDES,
|
|
20
|
+
USAGE_LIMIT_PATTERNS,
|
|
21
|
+
WORKTREE_TIMEOUT,
|
|
22
|
+
WORKTREES_DIR,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_local = Path(__file__).parent.parent.parent / "pycastle" / "config.py"
|
|
26
|
+
if _local.exists():
|
|
27
|
+
_spec = _util.spec_from_file_location("_pycastle_local_config", _local)
|
|
28
|
+
if _spec is not None and _spec.loader is not None:
|
|
29
|
+
_mod = _util.module_from_spec(_spec)
|
|
30
|
+
_spec.loader.exec_module(_mod)
|
|
31
|
+
_me = _sys.modules[__name__]
|
|
32
|
+
for _k, _v in vars(_mod).items():
|
|
33
|
+
if not _k.startswith("_"):
|
|
34
|
+
setattr(_me, _k, _v)
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
import tarfile
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextlib import AsyncExitStack
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import docker
|
|
16
|
+
from docker.models.containers import Container as DockerContainer
|
|
17
|
+
|
|
18
|
+
from .config import (
|
|
19
|
+
DOCKER_IMAGE_NAME,
|
|
20
|
+
IDLE_TIMEOUT,
|
|
21
|
+
LOGS_DIR,
|
|
22
|
+
PREFLIGHT_CHECKS,
|
|
23
|
+
PYCASTLE_DIR,
|
|
24
|
+
USAGE_LIMIT_PATTERNS,
|
|
25
|
+
)
|
|
26
|
+
from .errors import (
|
|
27
|
+
AgentTimeoutError,
|
|
28
|
+
BranchCollisionError,
|
|
29
|
+
DockerError,
|
|
30
|
+
DockerTimeoutError,
|
|
31
|
+
PreflightError,
|
|
32
|
+
UsageLimitError,
|
|
33
|
+
)
|
|
34
|
+
from .git_service import GitService
|
|
35
|
+
from .worktree import (
|
|
36
|
+
CONTAINER_PARENT_GIT,
|
|
37
|
+
create_worktree,
|
|
38
|
+
patch_gitdir_for_container,
|
|
39
|
+
remove_worktree,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_branch_locks: dict[str, asyncio.Lock] = {}
|
|
43
|
+
_usage_limit_halt: asyncio.Event = asyncio.Event()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def reset_usage_limit_flag() -> None:
|
|
47
|
+
_usage_limit_halt.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_usage_limit_line(line: str) -> bool:
|
|
51
|
+
"""Return True only if line is a plain-text usage-limit message, not JSON."""
|
|
52
|
+
try:
|
|
53
|
+
if isinstance(json.loads(line), dict):
|
|
54
|
+
return False
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
pass
|
|
57
|
+
line_lower = line.lower()
|
|
58
|
+
return any(p.lower() in line_lower for p in USAGE_LIMIT_PATTERNS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_stream_line(line: str) -> str | None:
|
|
62
|
+
"""Return a human-readable summary of a stream-json line, or None to suppress it."""
|
|
63
|
+
try:
|
|
64
|
+
obj = json.loads(line)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
return line
|
|
67
|
+
if not isinstance(obj, dict):
|
|
68
|
+
return line
|
|
69
|
+
line_type = obj.get("type")
|
|
70
|
+
if line_type == "system":
|
|
71
|
+
return None
|
|
72
|
+
if line_type == "result":
|
|
73
|
+
return None
|
|
74
|
+
if line_type == "assistant":
|
|
75
|
+
content = (obj.get("message") or {}).get("content", [])
|
|
76
|
+
parts: list[str] = []
|
|
77
|
+
for block in content:
|
|
78
|
+
if not isinstance(block, dict):
|
|
79
|
+
continue
|
|
80
|
+
if block.get("type") == "text":
|
|
81
|
+
text = block.get("text", "").strip()
|
|
82
|
+
if text:
|
|
83
|
+
parts.append(text)
|
|
84
|
+
elif block.get("type") == "tool_use":
|
|
85
|
+
parts.append(f"(tool: {block.get('name', 'unknown')})")
|
|
86
|
+
return " ".join(parts) if parts else None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_claude_command(model: str = "", effort: str = "") -> str:
|
|
91
|
+
flags = "--verbose --dangerously-skip-permissions --output-format stream-json -p -"
|
|
92
|
+
if model:
|
|
93
|
+
flags += f" --model {model}"
|
|
94
|
+
if effort:
|
|
95
|
+
flags += f" --effort {effort}"
|
|
96
|
+
return f"claude {flags} < /tmp/.pycastle_prompt"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ContainerRunner:
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
name: str,
|
|
103
|
+
mount_path: Path,
|
|
104
|
+
env: dict[str, str],
|
|
105
|
+
branch: str | None = None,
|
|
106
|
+
worktree_host_path: Path | None = None,
|
|
107
|
+
gitdir_overlay: Path | None = None,
|
|
108
|
+
model: str = "",
|
|
109
|
+
effort: str = "",
|
|
110
|
+
docker_client=None,
|
|
111
|
+
):
|
|
112
|
+
self.name = name
|
|
113
|
+
self.mount_path = mount_path
|
|
114
|
+
self.env = env
|
|
115
|
+
self.branch = branch
|
|
116
|
+
self.worktree_host_path = worktree_host_path
|
|
117
|
+
self.gitdir_overlay = gitdir_overlay
|
|
118
|
+
self.model = model
|
|
119
|
+
self.effort = effort
|
|
120
|
+
self._client = docker_client if docker_client is not None else docker.from_env()
|
|
121
|
+
self._container: DockerContainer | None = None
|
|
122
|
+
self._container_env: dict[str, str] = {}
|
|
123
|
+
self._prompt: str = ""
|
|
124
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
125
|
+
self._log_path = LOGS_DIR / f"{slug}.log"
|
|
126
|
+
self._worktree_path = "/home/agent/workspace"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def _active_container(self) -> DockerContainer:
|
|
130
|
+
if self._container is None:
|
|
131
|
+
raise DockerError("Container not started")
|
|
132
|
+
return self._container
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> "ContainerRunner":
|
|
135
|
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
repo_path = str(self.mount_path.resolve()).replace("\\", "/")
|
|
137
|
+
|
|
138
|
+
if self.worktree_host_path:
|
|
139
|
+
worktree_path = str(self.worktree_host_path.resolve()).replace("\\", "/")
|
|
140
|
+
parent_git_path = str((self.mount_path / ".git").resolve()).replace(
|
|
141
|
+
"\\", "/"
|
|
142
|
+
)
|
|
143
|
+
volumes = {
|
|
144
|
+
worktree_path: {"bind": "/home/agent/workspace", "mode": "rw"},
|
|
145
|
+
repo_path: {"bind": "/home/agent/repo", "mode": "ro"},
|
|
146
|
+
parent_git_path: {"bind": CONTAINER_PARENT_GIT, "mode": "rw"},
|
|
147
|
+
}
|
|
148
|
+
if self.gitdir_overlay:
|
|
149
|
+
overlay_path = str(self.gitdir_overlay.resolve()).replace("\\", "/")
|
|
150
|
+
volumes[overlay_path] = {
|
|
151
|
+
"bind": "/home/agent/workspace/.git",
|
|
152
|
+
"mode": "ro",
|
|
153
|
+
}
|
|
154
|
+
else:
|
|
155
|
+
volumes = {repo_path: {"bind": "/home/agent/workspace", "mode": "rw"}}
|
|
156
|
+
working_dir = "/home/agent/workspace"
|
|
157
|
+
|
|
158
|
+
# CLAUDE_ACCOUNT_JSON is written as a file inside the container, not passed as env var
|
|
159
|
+
self._container_env = {
|
|
160
|
+
k: v for k, v in self.env.items() if k != "CLAUDE_ACCOUNT_JSON"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
self._container = self._client.containers.run(
|
|
164
|
+
DOCKER_IMAGE_NAME,
|
|
165
|
+
detach=True,
|
|
166
|
+
volumes=volumes,
|
|
167
|
+
environment=self._container_env,
|
|
168
|
+
working_dir=working_dir,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
claude_json = self.env.get("CLAUDE_ACCOUNT_JSON")
|
|
172
|
+
if claude_json:
|
|
173
|
+
self.write_file(claude_json, "/home/agent/.claude.json")
|
|
174
|
+
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def __exit__(self, *_):
|
|
178
|
+
if self._container:
|
|
179
|
+
try:
|
|
180
|
+
self._container.stop(timeout=5)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
try:
|
|
184
|
+
self._container.remove(force=True)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def exec_simple(self, command: str, timeout: float | None = None) -> str:
|
|
189
|
+
import threading
|
|
190
|
+
|
|
191
|
+
container = self._active_container
|
|
192
|
+
result_holder: list = [None]
|
|
193
|
+
exc_holder: list = [None]
|
|
194
|
+
|
|
195
|
+
def _run():
|
|
196
|
+
try:
|
|
197
|
+
result_holder[0] = container.exec_run(
|
|
198
|
+
["bash", "-c", command],
|
|
199
|
+
demux=True,
|
|
200
|
+
workdir=self._worktree_path,
|
|
201
|
+
environment=self.env,
|
|
202
|
+
)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
exc_holder[0] = exc
|
|
205
|
+
|
|
206
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
207
|
+
thread.start()
|
|
208
|
+
thread.join(timeout=timeout)
|
|
209
|
+
|
|
210
|
+
if thread.is_alive():
|
|
211
|
+
raise DockerTimeoutError(f"Command timed out after {timeout}s: {command}")
|
|
212
|
+
|
|
213
|
+
if exc_holder[0]:
|
|
214
|
+
raise exc_holder[0]
|
|
215
|
+
|
|
216
|
+
result = result_holder[0]
|
|
217
|
+
stdout = (result.output[0] or b"").decode("utf-8", errors="replace")
|
|
218
|
+
stderr = (result.output[1] or b"").decode("utf-8", errors="replace")
|
|
219
|
+
if result.exit_code != 0:
|
|
220
|
+
raise DockerError(
|
|
221
|
+
f"Command failed (exit {result.exit_code}): {stderr.strip() or stdout.strip()}"
|
|
222
|
+
)
|
|
223
|
+
if stderr and not stdout:
|
|
224
|
+
print(f" [exec stderr] {stderr.strip()}", file=sys.stderr)
|
|
225
|
+
return stdout
|
|
226
|
+
|
|
227
|
+
def write_file(self, content: str, container_path: str):
|
|
228
|
+
data = content.encode("utf-8")
|
|
229
|
+
buf = io.BytesIO()
|
|
230
|
+
with tarfile.open(fileobj=buf, mode="w") as tar:
|
|
231
|
+
info = tarfile.TarInfo(name=os.path.basename(container_path))
|
|
232
|
+
info.size = len(data)
|
|
233
|
+
tar.addfile(info, io.BytesIO(data))
|
|
234
|
+
buf.seek(0)
|
|
235
|
+
self._active_container.put_archive(os.path.dirname(container_path), buf)
|
|
236
|
+
|
|
237
|
+
def run_streaming(self) -> str:
|
|
238
|
+
self.write_file(self._prompt, "/tmp/.pycastle_prompt")
|
|
239
|
+
result = self._active_container.exec_run(
|
|
240
|
+
["bash", "-c", _build_claude_command(model=self.model, effort=self.effort)],
|
|
241
|
+
stream=True,
|
|
242
|
+
workdir=self._worktree_path,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
q: queue.Queue = queue.Queue()
|
|
246
|
+
_sentinel = object()
|
|
247
|
+
|
|
248
|
+
def _feed():
|
|
249
|
+
try:
|
|
250
|
+
for chunk in result.output:
|
|
251
|
+
q.put(chunk)
|
|
252
|
+
finally:
|
|
253
|
+
q.put(_sentinel)
|
|
254
|
+
|
|
255
|
+
threading.Thread(target=_feed, daemon=True).start()
|
|
256
|
+
|
|
257
|
+
parts: list[str] = []
|
|
258
|
+
line_buf = ""
|
|
259
|
+
try:
|
|
260
|
+
with open(self._log_path, "wb") as log:
|
|
261
|
+
while True:
|
|
262
|
+
try:
|
|
263
|
+
chunk = q.get(timeout=IDLE_TIMEOUT)
|
|
264
|
+
except queue.Empty:
|
|
265
|
+
raise AgentTimeoutError(
|
|
266
|
+
f"Agent idle for more than {IDLE_TIMEOUT}s"
|
|
267
|
+
)
|
|
268
|
+
if chunk is _sentinel:
|
|
269
|
+
break
|
|
270
|
+
log.write(chunk)
|
|
271
|
+
log.flush()
|
|
272
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
273
|
+
parts.append(text)
|
|
274
|
+
line_buf += text
|
|
275
|
+
while "\n" in line_buf:
|
|
276
|
+
line, line_buf = line_buf.split("\n", 1)
|
|
277
|
+
if _is_usage_limit_line(line):
|
|
278
|
+
raise UsageLimitError(line)
|
|
279
|
+
formatted = _format_stream_line(line)
|
|
280
|
+
if formatted is not None:
|
|
281
|
+
print(f"[{self.name}] {formatted}", flush=True)
|
|
282
|
+
finally:
|
|
283
|
+
try:
|
|
284
|
+
self._active_container.exec_run(
|
|
285
|
+
["bash", "-c", "rm -f /tmp/.pycastle_prompt"],
|
|
286
|
+
workdir=self._worktree_path,
|
|
287
|
+
)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
return "".join(parts)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _preflight(
|
|
294
|
+
name: str,
|
|
295
|
+
runner: "ContainerRunner",
|
|
296
|
+
loop: asyncio.AbstractEventLoop,
|
|
297
|
+
exec_timeout: float | None,
|
|
298
|
+
checks: list[tuple[str, str]],
|
|
299
|
+
) -> list[tuple[str, str, str]]:
|
|
300
|
+
print(f"[{name}] Phase: Pre-flight")
|
|
301
|
+
failures: list[tuple[str, str, str]] = []
|
|
302
|
+
for check_name, command in checks:
|
|
303
|
+
try:
|
|
304
|
+
await loop.run_in_executor(None, runner.exec_simple, command, exec_timeout)
|
|
305
|
+
except DockerError as exc:
|
|
306
|
+
failures.append((check_name, command, str(exc)))
|
|
307
|
+
return failures
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _setup(
|
|
311
|
+
name: str,
|
|
312
|
+
runner: "ContainerRunner",
|
|
313
|
+
loop: asyncio.AbstractEventLoop,
|
|
314
|
+
exec_timeout: float | None,
|
|
315
|
+
git_service: GitService | None = None,
|
|
316
|
+
) -> None:
|
|
317
|
+
print(f"[{name}] Phase: Setup")
|
|
318
|
+
await loop.run_in_executor(None, runner.__enter__)
|
|
319
|
+
if git_service is None:
|
|
320
|
+
git_service = GitService()
|
|
321
|
+
git_name = git_service.get_user_name()
|
|
322
|
+
git_email = git_service.get_user_email()
|
|
323
|
+
await loop.run_in_executor(
|
|
324
|
+
None,
|
|
325
|
+
runner.exec_simple,
|
|
326
|
+
f"git config --global user.name {shlex.quote(git_name)}",
|
|
327
|
+
exec_timeout,
|
|
328
|
+
)
|
|
329
|
+
await loop.run_in_executor(
|
|
330
|
+
None,
|
|
331
|
+
runner.exec_simple,
|
|
332
|
+
f"git config --global user.email {shlex.quote(git_email)}",
|
|
333
|
+
exec_timeout,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def _prepare(
|
|
338
|
+
name: str,
|
|
339
|
+
runner: "ContainerRunner",
|
|
340
|
+
loop: asyncio.AbstractEventLoop,
|
|
341
|
+
exec_timeout: float | None,
|
|
342
|
+
prompt_file: Path,
|
|
343
|
+
prompt_args: dict[str, str],
|
|
344
|
+
) -> None:
|
|
345
|
+
from .prompt_pipeline import prepare_prompt
|
|
346
|
+
|
|
347
|
+
print(f"[{name}] Phase: Prepare")
|
|
348
|
+
try:
|
|
349
|
+
await loop.run_in_executor(
|
|
350
|
+
None,
|
|
351
|
+
runner.exec_simple,
|
|
352
|
+
"pip install -e '.[dev]' || pip install -r requirements.txt",
|
|
353
|
+
exec_timeout,
|
|
354
|
+
)
|
|
355
|
+
except RuntimeError as exc:
|
|
356
|
+
print(f" [{name}] Warning: dependency install skipped: {exc}", file=sys.stderr)
|
|
357
|
+
|
|
358
|
+
async def container_exec(cmd: str) -> str:
|
|
359
|
+
return await loop.run_in_executor(None, runner.exec_simple, cmd, exec_timeout)
|
|
360
|
+
|
|
361
|
+
prompt = await prepare_prompt(prompt_file, prompt_args, container_exec)
|
|
362
|
+
runner._prompt = prompt
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def _work(
|
|
366
|
+
name: str,
|
|
367
|
+
runner: "ContainerRunner",
|
|
368
|
+
loop: asyncio.AbstractEventLoop,
|
|
369
|
+
) -> str:
|
|
370
|
+
print(f"[{name}] Phase: Work")
|
|
371
|
+
return await loop.run_in_executor(None, runner.run_streaming)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def run_agent(
|
|
375
|
+
name: str,
|
|
376
|
+
prompt_file: Path,
|
|
377
|
+
mount_path: Path,
|
|
378
|
+
env: dict[str, str],
|
|
379
|
+
prompt_args: dict[str, str] | None = None,
|
|
380
|
+
branch: str | None = None,
|
|
381
|
+
exec_timeout: float | None = None,
|
|
382
|
+
skip_preflight: bool = False,
|
|
383
|
+
model: str = "",
|
|
384
|
+
effort: str = "",
|
|
385
|
+
git_service: GitService | None = None,
|
|
386
|
+
stage: str = "",
|
|
387
|
+
sha: str | None = None,
|
|
388
|
+
create_worktree_fn: Callable[[Path, Path, str, str | None], None] = create_worktree,
|
|
389
|
+
remove_worktree_fn: Callable[[Path, Path], None] = remove_worktree,
|
|
390
|
+
) -> str:
|
|
391
|
+
if _usage_limit_halt.is_set():
|
|
392
|
+
return ""
|
|
393
|
+
|
|
394
|
+
print(f"\n[{name}] Started")
|
|
395
|
+
|
|
396
|
+
lock: asyncio.Lock | None = None
|
|
397
|
+
if branch:
|
|
398
|
+
if branch not in _branch_locks:
|
|
399
|
+
_branch_locks[branch] = asyncio.Lock()
|
|
400
|
+
lock = _branch_locks[branch]
|
|
401
|
+
if lock.locked():
|
|
402
|
+
raise BranchCollisionError(
|
|
403
|
+
f"Branch {branch!r} already has an agent running"
|
|
404
|
+
)
|
|
405
|
+
await lock.acquire()
|
|
406
|
+
|
|
407
|
+
worktree_host_path: Path | None = None
|
|
408
|
+
gitdir_overlay: Path | None = None
|
|
409
|
+
try:
|
|
410
|
+
if branch:
|
|
411
|
+
m = re.search(r"issue-(\d+)", branch)
|
|
412
|
+
worktree_name = (
|
|
413
|
+
f"issue-{m.group(1)}"
|
|
414
|
+
if m
|
|
415
|
+
else re.sub(r"[^a-z0-9]+", "-", branch.lower()).strip("-")
|
|
416
|
+
)
|
|
417
|
+
worktree_host_path = (
|
|
418
|
+
mount_path / PYCASTLE_DIR / ".worktrees" / worktree_name
|
|
419
|
+
)
|
|
420
|
+
create_worktree_fn(mount_path, worktree_host_path, branch, sha)
|
|
421
|
+
gitdir_overlay = patch_gitdir_for_container(worktree_host_path)
|
|
422
|
+
|
|
423
|
+
loop = asyncio.get_event_loop()
|
|
424
|
+
runner = ContainerRunner(
|
|
425
|
+
name,
|
|
426
|
+
mount_path,
|
|
427
|
+
env,
|
|
428
|
+
branch=branch,
|
|
429
|
+
worktree_host_path=worktree_host_path,
|
|
430
|
+
gitdir_overlay=gitdir_overlay,
|
|
431
|
+
model=model,
|
|
432
|
+
effort=effort,
|
|
433
|
+
)
|
|
434
|
+
preserve_worktree = [False]
|
|
435
|
+
|
|
436
|
+
async with AsyncExitStack() as stack:
|
|
437
|
+
if gitdir_overlay:
|
|
438
|
+
stack.callback(gitdir_overlay.unlink, missing_ok=True)
|
|
439
|
+
if worktree_host_path:
|
|
440
|
+
|
|
441
|
+
def _cond_remove_worktree():
|
|
442
|
+
if not preserve_worktree[0]:
|
|
443
|
+
remove_worktree_fn(mount_path, worktree_host_path)
|
|
444
|
+
|
|
445
|
+
stack.callback(_cond_remove_worktree)
|
|
446
|
+
stack.callback(runner.__exit__, None, None, None)
|
|
447
|
+
await _setup(name, runner, loop, exec_timeout, git_service)
|
|
448
|
+
await _prepare(
|
|
449
|
+
name, runner, loop, exec_timeout, prompt_file, prompt_args or {}
|
|
450
|
+
)
|
|
451
|
+
if not skip_preflight:
|
|
452
|
+
failures = await _preflight(
|
|
453
|
+
name, runner, loop, exec_timeout, PREFLIGHT_CHECKS
|
|
454
|
+
)
|
|
455
|
+
if failures:
|
|
456
|
+
raise PreflightError(failures)
|
|
457
|
+
try:
|
|
458
|
+
return await _work(name, runner, loop)
|
|
459
|
+
except UsageLimitError:
|
|
460
|
+
_usage_limit_halt.set()
|
|
461
|
+
preserve_worktree[0] = True
|
|
462
|
+
raise
|
|
463
|
+
finally:
|
|
464
|
+
if lock is not None and lock.locked():
|
|
465
|
+
lock.release()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
ARG PYTHON_VERSION=3.12
|
|
2
|
+
FROM python:${PYTHON_VERSION}-slim-bookworm
|
|
3
|
+
|
|
4
|
+
# Install system dependencies
|
|
5
|
+
RUN apt-get update && apt-get install -y \
|
|
6
|
+
git \
|
|
7
|
+
curl \
|
|
8
|
+
jq \
|
|
9
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
10
|
+
|
|
11
|
+
# Install GitHub CLI
|
|
12
|
+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
13
|
+
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
|
14
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
15
|
+
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
|
16
|
+
&& apt-get update && apt-get install -y gh \
|
|
17
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
18
|
+
|
|
19
|
+
# Create agent user with UID 1000 for compatibility with --userns=keep-id and --user 1000:1000
|
|
20
|
+
RUN useradd -m -u 1000 -s /bin/bash agent
|
|
21
|
+
USER agent
|
|
22
|
+
|
|
23
|
+
# Trust any bind-mounted directory regardless of host ownership
|
|
24
|
+
RUN git config --global --add safe.directory '*'
|
|
25
|
+
|
|
26
|
+
# Install Claude Code CLI
|
|
27
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
28
|
+
|
|
29
|
+
# Add Claude to PATH
|
|
30
|
+
ENV PATH="/home/agent/.local/bin:$PATH"
|
|
31
|
+
|
|
32
|
+
WORKDIR /home/agent
|
|
33
|
+
|
|
34
|
+
ENTRYPOINT ["sleep", "infinity"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
# --- Behaviour ---
|
|
4
|
+
MAX_ITERATIONS = 10
|
|
5
|
+
MAX_PARALLEL = 1
|
|
6
|
+
WORKTREE_TIMEOUT = 30
|
|
7
|
+
IDLE_TIMEOUT = 300
|
|
8
|
+
|
|
9
|
+
# --- Docker ---
|
|
10
|
+
DOCKER_IMAGE_NAME = ""
|
|
11
|
+
|
|
12
|
+
# --- Labels ---
|
|
13
|
+
ISSUE_LABEL = "ready-for-agent"
|
|
14
|
+
HITL_LABEL = "ready-for-human"
|
|
15
|
+
|
|
16
|
+
# --- Paths ---
|
|
17
|
+
PYCASTLE_DIR = Path("pycastle")
|
|
18
|
+
PROMPTS_DIR = Path("pycastle/prompts")
|
|
19
|
+
LOGS_DIR = Path("pycastle/logs")
|
|
20
|
+
WORKTREES_DIR = Path("worktrees")
|
|
21
|
+
ENV_FILE = Path("pycastle/.env")
|
|
22
|
+
DOCKERFILE = Path("pycastle/Dockerfile")
|
|
23
|
+
|
|
24
|
+
# --- Checks ---
|
|
25
|
+
PREFLIGHT_CHECKS: list[tuple[str, str]] = [
|
|
26
|
+
("ruff", "ruff check ."),
|
|
27
|
+
("mypy", "mypy ."),
|
|
28
|
+
("pytest", "pytest"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
IMPLEMENT_CHECKS: list[str] = [
|
|
32
|
+
"ruff check --fix",
|
|
33
|
+
"ruff format --check",
|
|
34
|
+
"mypy .",
|
|
35
|
+
"pytest",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# --- Usage limit detection ---
|
|
39
|
+
USAGE_LIMIT_PATTERNS: list[str] = ["You've hit your", "Credit balance is too low"]
|
|
40
|
+
|
|
41
|
+
# --- Stage overrides ---
|
|
42
|
+
STAGE_OVERRIDES: dict[str, dict[str, str]] = {
|
|
43
|
+
"plan": {"model": "", "effort": ""},
|
|
44
|
+
"implement": {"model": "", "effort": ""},
|
|
45
|
+
"review": {"model": "", "effort": ""},
|
|
46
|
+
"merge": {"model": "", "effort": ""},
|
|
47
|
+
}
|